Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7eacd1eb85 | ||
|
|
f3c7a809f4 | ||
|
|
c1b5378045 | ||
|
|
3d6f53c16d | ||
|
|
8ceee5285b | ||
|
|
a181f1a5f0 | ||
|
|
7251f00e91 | ||
|
|
cb38f79aa3 | ||
|
|
54e9921efb | ||
|
|
f252b09757 | ||
|
|
a2992b3b9d | ||
|
|
7b67db5ced | ||
|
|
84c9ade8e3 | ||
|
|
9e26cd0285 | ||
|
|
73e60f1151 | ||
|
|
088e8f3b0d | ||
|
|
261d5dbf84 | ||
|
|
5f9d07aff1 | ||
|
|
3fb7b9be6b | ||
|
|
6c3a50ce7f | ||
|
|
f8304a0e86 | ||
|
|
e63f39f8c2 | ||
|
|
6f100dbe5e | ||
|
|
7c5ef269cd | ||
|
|
f13b6fc0ef | ||
|
|
3d61fc637b | ||
|
|
889b2aa4a9 | ||
|
|
8bd3867cb3 | ||
|
|
9577387c2f | ||
|
|
7a087b8827 | ||
|
|
7099106802 | ||
|
|
43385d36ee | ||
|
|
4b97f401cb | ||
|
|
af869944cf | ||
|
|
4b90abf3b5 | ||
|
|
61266fc9b1 | ||
|
|
d18655538e | ||
|
|
da7ea0ec56 | ||
|
|
127b71efd0 | ||
|
|
2339b17d97 | ||
|
|
bd4f797d1f | ||
|
|
e872cad058 | ||
|
|
081c8fb889 |
No files matched your search
@@ -45,7 +45,7 @@ jobs:
|
||||
run: pacman -Syu --noconfirm --needed git openssh pacman-contrib
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
# The updated recipes are committed back to main, not to the tag.
|
||||
ref: main
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
rust:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install native dependencies
|
||||
run: |
|
||||
@@ -32,8 +32,14 @@ jobs:
|
||||
with:
|
||||
toolchain: '1.96'
|
||||
components: rustfmt, clippy
|
||||
cache: false
|
||||
|
||||
# Tags can only restore caches saved on main, so only main saves one. The
|
||||
# release workflow restores this key for its quality gates.
|
||||
- uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: debug
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Formatting
|
||||
run: cargo fmt --all -- --check
|
||||
@@ -57,3 +63,37 @@ jobs:
|
||||
|
||||
# Real Proton credentials and /dev/fuse deliberately do not run on pull
|
||||
# requests. Follow docs/TESTING.md on a dedicated test-account runner.
|
||||
|
||||
# Keeps the release-profile dependencies cached on main, where the release
|
||||
# workflow can restore them; a tag cannot reuse the caches of earlier tags.
|
||||
release-cache:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install native dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
pkg-config \
|
||||
libfuse3-dev \
|
||||
libgtk-4-dev \
|
||||
libadwaita-1-dev \
|
||||
libwebkitgtk-6.0-dev \
|
||||
libsecret-1-dev \
|
||||
libdbus-1-dev
|
||||
|
||||
- name: Install minimum supported Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: '1.96'
|
||||
cache: false
|
||||
|
||||
- uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: release
|
||||
|
||||
- name: Release build
|
||||
run: cargo build --release --locked
|
||||
@@ -10,14 +10,66 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
name: Release quality gates
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
pkg-config \
|
||||
libfuse3-dev \
|
||||
libgtk-4-dev \
|
||||
libadwaita-1-dev \
|
||||
libwebkitgtk-6.0-dev \
|
||||
libsecret-1-dev \
|
||||
libdbus-1-dev
|
||||
|
||||
- name: Set up Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: '1.96'
|
||||
components: rustfmt, clippy
|
||||
cache: false
|
||||
|
||||
# A tag can read the caches of main but its own caches are useless to the next
|
||||
# tag, so this only restores what CI saved on main.
|
||||
- name: Cache Cargo target
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: debug
|
||||
save-if: false
|
||||
|
||||
- name: Verify tag and manifest versions
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
shell: bash
|
||||
run: |
|
||||
tag_version="${GITHUB_REF_NAME#v}"
|
||||
manifest_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n1)"
|
||||
pkgbuild_version="$(sed -n 's/^pkgver=//p' packaging/PKGBUILD | head -n1)"
|
||||
test "$tag_version" = "$manifest_version"
|
||||
test "$tag_version" = "$pkgbuild_version"
|
||||
|
||||
- name: Release quality gates
|
||||
run: |
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace --locked
|
||||
scripts/fuse-acceptance.sh --offline-only
|
||||
|
||||
build-release:
|
||||
name: Build and Release
|
||||
name: Build packages
|
||||
runs-on: ubuntu-24.04
|
||||
environment: 'rpm signing'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
@@ -38,27 +90,14 @@ jobs:
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: '1.96'
|
||||
components: rustfmt, clippy
|
||||
cache: false
|
||||
|
||||
# Restores the release-profile dependencies that CI builds on main.
|
||||
- name: Cache Cargo target
|
||||
uses: swatinem/rust-cache@v2
|
||||
|
||||
- name: Verify tag and manifest versions
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
shell: bash
|
||||
run: |
|
||||
tag_version="${GITHUB_REF_NAME#v}"
|
||||
manifest_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n1)"
|
||||
pkgbuild_version="$(sed -n 's/^pkgver=//p' packaging/PKGBUILD | head -n1)"
|
||||
test "$tag_version" = "$manifest_version"
|
||||
test "$tag_version" = "$pkgbuild_version"
|
||||
|
||||
- name: Release quality gates
|
||||
run: |
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace --locked
|
||||
scripts/fuse-acceptance.sh --offline-only
|
||||
with:
|
||||
shared-key: release
|
||||
save-if: false
|
||||
|
||||
- name: Build release binaries
|
||||
run: |
|
||||
@@ -127,7 +166,7 @@ jobs:
|
||||
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, libimage-exiftool-perl
|
||||
Installed-Size: ${INSTALLED_SIZE}
|
||||
Homepage: https://github.com/narrrl/proton-drive-linux
|
||||
Homepage: https://proton-drive.narl.io
|
||||
Description: Unofficial Proton Drive client for Linux
|
||||
Files-on-demand Proton Drive for the Linux desktop, unaffiliated with Proton AG:
|
||||
- FUSE virtual mount with block-level caching
|
||||
@@ -152,6 +191,7 @@ jobs:
|
||||
--define "_topdir $PWD/rpmbuild" \
|
||||
--define "_sourcedir $PWD" \
|
||||
--define "git_dir $PWD" \
|
||||
--define "prebuilt 1" \
|
||||
--define "version ${RPM_VERSION}" \
|
||||
--define "_dbpath $PWD/rpmbuild/db" \
|
||||
packaging/proton-drive-linux.spec
|
||||
@@ -181,7 +221,7 @@ jobs:
|
||||
|
||||
|
||||
- name: Upload Workflow Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: proton-drive-linux-builds
|
||||
path: |
|
||||
@@ -190,9 +230,20 @@ jobs:
|
||||
*.rpm
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: Create GitHub Release
|
||||
needs: [checks, build-release]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Download packages
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: proton-drive-linux-builds
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: |
|
||||
*.deb
|
||||
@@ -205,7 +256,7 @@ jobs:
|
||||
|
||||
aur:
|
||||
name: Publish AUR packages
|
||||
needs: build-release
|
||||
needs: publish
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: ./.github/workflows/aur.yml
|
||||
with:
|
||||
|
||||
@@ -6,9 +6,10 @@ 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.
|
||||
- Read [`AGENTS.md`](AGENTS.md) for repository conventions,
|
||||
[`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) for building, running and debugging from source,
|
||||
and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how the daemon, front ends, and SDK fit
|
||||
together. [`docs/README.md`](docs/README.md) indexes the rest.
|
||||
- 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
|
||||
@@ -23,8 +24,9 @@ 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.
|
||||
development packages listed in [`docs/INSTALL.md`](docs/INSTALL.md#build-dependencies). Minimum
|
||||
supported Rust version is 1.96. [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md#running-from-source)
|
||||
explains how to run your build next to, or instead of, the installed service.
|
||||
|
||||
## Quality gates
|
||||
|
||||
@@ -51,6 +53,13 @@ use a dedicated test account, never one holding data you cannot lose.
|
||||
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.
|
||||
- **The GUI is fully translated.** Wrap every user-visible string in the helpers from
|
||||
`crates/pdfs-gui/src/i18n.rs`, run `po/update.sh`, and translate the new entries in every
|
||||
catalog in `po/LINGUAS` in the same change. See [`docs/TRANSLATING.md`](docs/TRANSLATING.md).
|
||||
The CLI and daemon messages stay in English.
|
||||
- **Document what users see.** A new command, setting or behaviour belongs in the matching page
|
||||
under `docs/` (user guide, CLI reference or configuration) and in the `[Unreleased]` section of
|
||||
[`docs/CHANGELOG.md`](docs/CHANGELOG.md).
|
||||
|
||||
## Commits and pull requests
|
||||
|
||||
|
||||
@@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-cli"
|
||||
version = "2.4.0"
|
||||
version = "2.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3489,7 +3489,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-core"
|
||||
version = "2.4.0"
|
||||
version = "2.8.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dbus-secret-service-keyring-store",
|
||||
@@ -3513,7 +3513,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-fuse"
|
||||
version = "2.4.0"
|
||||
version = "2.8.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"fuser",
|
||||
@@ -3533,7 +3533,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-gui"
|
||||
version = "2.4.0"
|
||||
version = "2.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3773,9 +3773,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-drive-rs"
|
||||
version = "0.7.0"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0f6996724e51e99f03d2843828de9477afc39baa764403cb69490cabc1c4d93"
|
||||
checksum = "8807e007328fd08cad9fe93c84dd7cdb38dc97469250edf3d6b58c6df0ac0d4f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -3800,9 +3800,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-sdk"
|
||||
version = "0.7.0"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91cc25654133bd5714407a1df0996eaf214a6e8e0bb95d8139bc42e7cee31c34"
|
||||
checksum = "085cd6506a49d8892a2b7ed6b5db3909750d9844610178f55979ed330d9fe715"
|
||||
dependencies = [
|
||||
"aes-gcm 0.11.1",
|
||||
"async-trait",
|
||||
|
||||
@@ -3,20 +3,20 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "2.4.0"
|
||||
version = "2.8.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
rust-version = "1.96"
|
||||
repository = "https://github.com/narrrl/proton-drive-linux"
|
||||
homepage = "https://github.com/narrrl/proton-drive-linux"
|
||||
homepage = "https://proton-drive.narl.io"
|
||||
readme = "README.md"
|
||||
keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
||||
categories = ["filesystem", "command-line-utilities"]
|
||||
|
||||
[workspace.dependencies]
|
||||
proton-sdk = { version = "0.7.0" }
|
||||
proton-drive-rs = { version = "0.7.0" }
|
||||
proton-sdk = { version = "0.7.1" }
|
||||
proton-drive-rs = { version = "0.7.1" }
|
||||
|
||||
# Internal crates.
|
||||
pdfs-core = { path = "crates/pdfs-core" }
|
||||
|
||||
@@ -4,513 +4,164 @@
|
||||
|
||||
# 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.
|
||||
**Proton Drive on the Linux desktop.** Your files as a folder that downloads on demand, synced
|
||||
local folders, Photos, sharing, and a native GTK4 app with tray and search launcher, plus a
|
||||
scriptable CLI.
|
||||
|
||||
**[proton-drive.narl.io](https://proton-drive.narl.io)**
|
||||
|
||||
[](https://github.com/narrrl/proton-drive-linux/actions/workflows/ci.yml)
|
||||
[](https://github.com/narrrl/proton-drive-linux/releases/latest)
|
||||
[](https://aur.archlinux.org/packages/proton-drive-for-linux)
|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org)
|
||||
[](#prerequisites)
|
||||
|
||||
<img src="images/files.png" alt="The GTK4 file browser" width="820">
|
||||
<img src="images/files.png" alt="My files in the Proton Drive app" width="820">
|
||||
|
||||
</div>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This is an **unofficial**, community-built client. It is not affiliated with, endorsed by, or
|
||||
> supported by Proton AG. "Proton" and "Proton Drive" are trademarks of Proton AG, used here only
|
||||
> to describe what the software talks to. Use a test account first, and read
|
||||
> [`docs/RECOVERY.md`](docs/RECOVERY.md) before trusting it with data you cannot lose.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# Debian / Ubuntu
|
||||
sudo apt install ./proton-drive-linux_*.deb
|
||||
|
||||
# Fedora
|
||||
sudo dnf install ./proton-drive-linux-*.rpm
|
||||
|
||||
# Arch Linux (AUR)
|
||||
paru -S proton-drive-for-linux # or -git / -bin
|
||||
```
|
||||
|
||||
Packages for each release are attached to the [latest
|
||||
release](https://github.com/narrrl/proton-drive-linux/releases/latest); see
|
||||
[Installation & Packages](#installation--packages) for details and
|
||||
[Building from Source](#building-from-source) to compile it yourself.
|
||||
|
||||
```bash
|
||||
pdfs login # sign in (SRP + 2FA, credentials go to the system keyring)
|
||||
pdfs status # mount state, queued writes, cache usage
|
||||
pdfs-app # the desktop app
|
||||
```
|
||||
|
||||
## Contents
|
||||
|
||||
- [Features](#features) · [Quick search](#quick-search-and-media-opening) · [Opening files](#choosing-how-files-open)
|
||||
- [Selective sync](#selective-sync-pdfsignore) · [Diagnostics](#diagnostics--maintenance) · [Scripting](#scripting---json)
|
||||
- [Performance](#performance--caching) · [Filesystem safety](#filesystem-safety) · [Screenshots](#screenshots)
|
||||
- [Prerequisites](#prerequisites) · [Building](#building-from-source) · [Packages](#installation--packages) · [Releases](#automated-releases-cicd)
|
||||
|
||||
**Documentation:** [Architecture](docs/ARCHITECTURE.md) · [Testing](docs/TESTING.md) ·
|
||||
[Recovery](docs/RECOVERY.md) · [Development](docs/DEVELOPMENT.md) · [Translating](docs/TRANSLATING.md) ·
|
||||
[Known issues](docs/BUGS.md) · [Changelog](docs/CHANGELOG.md) ·
|
||||
[Contributing](CONTRIBUTING.md) · [Security policy](SECURITY.md)
|
||||
> to describe what the software talks to. Try it with data you have another copy of first, and
|
||||
> read [what survives a lost machine](docs/RECOVERY.md) before you rely on it.
|
||||
|
||||
## Features
|
||||
|
||||
- **Files-on-Demand (FUSE)**: Mount your Proton Drive as a virtual filesystem where files are downloaded only when opened, utilizing smart block-level caching and disk-backed writes.
|
||||
- **Command-Line Interface (CLI)**: Manage your drive, authenticate, and monitor sync status directly from the terminal.
|
||||
- **Non-Blocking GTK4 Desktop App**: Browse files, manage pins, configure options, and monitor active transfers through a modern, native GUI with a fully non-blocking asynchronous main loop.
|
||||
- **File Thumbnails**: Browse locally cached image previews in Files, Shared, and Trash, including embedded previews from camera RAW files. Build a whole folder tree from the Files toolbar and resize the grid from the status bar.
|
||||
- **Shared Links & Invites**: Browse files shared with you by other users, and view/manage your own public shared links directly in the GUI.
|
||||
- **Local Backup (Computers)**: Sync and back up local directories (like Downloads, Documents, Pictures, etc.) directly to your Proton Drive account.
|
||||
- **Locations**: One page (and `pdfs locations`) listing every place Proton Drive occupies on this machine — the main mount plus each backed-up folder — with its mode, sync state, and whether it is read-only. Switching a folder between a full local copy and on-demand happens here.
|
||||
- **System Tray Integration**: Background indicator for status monitoring, quick actions, and fast sync controls.
|
||||
- **Unified Search Launcher (HUD)**: A resident Google Drive-style launcher (`pdfs-prompt`) that searches Proton Drive and local files together, ranks the best matches, tolerates abbreviations and typos, and is ideal for a system-wide hotkey. It can also present the same search in your own launcher (`--dmenu`) or, searching live as you type, in `fzf` (`--fzf`).
|
||||
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages.
|
||||
- **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI).
|
||||
- **File Version History**: Every revision Proton Drive still holds for a file, from the browser's details pane (**Versions**) or `pdfs versions list|restore|save|rm`. Restoring is server-side — no re-upload — and an old version can be written out to a local file without touching the live one.
|
||||
- **Photo Favourites**: Star a photo in the lightbox, filter the gallery to favourites, or use `pdfs favorite <uid>` / `pdfs photos --favorites`.
|
||||
- **Photo Albums**: Browse your albums — including the ones shared with you — from the Albums view of the Photos page, or with `pdfs albums` / `pdfs album <uid>`. Album contents open in the same gallery as the timeline.
|
||||
- **Places**: The Places view of the Photos page groups photos by the town they were taken in, using the location stored with each photo, as cards or on a world map. Towns are looked up offline in [GeoNames](https://www.geonames.org/) data (CC BY 4.0), and the map is drawn offline from [Natural Earth](https://www.naturalearthdata.com/) land outlines and country borders (public domain). Each town is marked with one of its photos, and zoomed in, every photo sits where it was taken. A street map from [OpenFreeMap](https://openfreemap.org/) (© [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors) can be turned on under Appearance; it is off by default because the tile server learns which parts of the world you look at. Install `iso-codes` for country names in your language.
|
||||
- **Human Verification (CAPTCHA) Recovery**: Detects sign-in gates (VPN/new IP challenges) and launches an embedded `WebKitWebView` dialog to safely complete the challenge, transparently retrying authentication with the earned token.
|
||||
- **Selective Sync (`.pdfsignore`)**: Keep build trees, dependency directories, and editor leftovers out of synced folders using gitignore-style rules.
|
||||
- **Data-Safe Offline Writes**: Durable scratch/staging files and a transactional pending queue preserve acknowledged writes across network failures and restarts.
|
||||
**Files**
|
||||
|
||||
## Quick Search and Media Opening
|
||||
- **Your Drive as a folder.** `~/ProtonDrive` works with every program. Files download when they
|
||||
are opened, a block at a time, so a video starts playing and a large archive can be read
|
||||
without fetching all of it.
|
||||
- **Available offline.** Keep chosen files and folders fully downloaded; everything else is
|
||||
cached within a size limit you set.
|
||||
- **Synced folders.** Back up folders such as `~/Documents` under your computer in Proton Drive,
|
||||
as a full two-way copy or online only. Gitignore-style `.pdfsignore` rules, conflict copies
|
||||
instead of overwrites, pause and bandwidth limits.
|
||||
- **Safe writes.** Changes are staged on disk and queued before they are acknowledged, so they
|
||||
survive a lost connection, a crash or a reboot and upload when they can.
|
||||
|
||||
`pdfs-prompt` searches Drive metadata and the local home-directory index in one request. Results are ranked together rather than split by source. Matching is case-insensitive and supports exact names, prefixes, parent paths, multiple terms, punctuation-separated words, ordered abbreviations, common typos, and adjacent transpositions.
|
||||
**Desktop app**
|
||||
|
||||
The prompt stays resident after its first launch, so invoking the shortcut again reuses and resets the existing window. Drive folders open from the mount. Audio and video results also open through FUSE, allowing media players to seek and request ranges without downloading the entire file first; ordinary Drive files are materialized into the local cache before opening.
|
||||
- **Browse** My files, Shared with me, Shared by me, Computers and Trash, with thumbnails
|
||||
(including camera RAW), drag and drop, details and **version history**.
|
||||
- **Photos.** Timeline with favorites, albums, and Places on a map; RAW+JPEG pairs shown as one
|
||||
photo; duplicate finder; slideshow; **import from Google Photos** (Takeout).
|
||||
- **Sharing.** Invite people as viewer, editor or admin, create public links with password and
|
||||
expiry, accept invitations.
|
||||
- **Sync overview.** What is uploading, what is waiting, what failed and why, and a history of
|
||||
what happened.
|
||||
- **Tray icon** with status, pause and quick actions, and a **search launcher** (`pdfs-prompt`)
|
||||
that searches Proton Drive and your home folder together, as its own window, through fuzzel,
|
||||
rofi or wofi, or live in `fzf`.
|
||||
- **Restore a computer.** Continue a backup on a new machine and bring its folders back in one
|
||||
step.
|
||||
- **Sign-in** with two-factor authentication and CAPTCHA; the session is kept in the system
|
||||
keyring.
|
||||
- Available in English, German, Spanish, French, Italian, Dutch, Polish and Brazilian
|
||||
Portuguese.
|
||||
|
||||
### Using your own launcher (fuzzel, rofi, wofi, …)
|
||||
**Command line**
|
||||
|
||||
`pdfs-prompt --dmenu` runs the same search through an external dmenu-style
|
||||
launcher instead of the built-in window, so the HUD matches the rest of a
|
||||
tiling-WM desktop:
|
||||
- About 55 `pdfs` commands cover files, sync, sharing, Photos, Trash, versions and diagnostics.
|
||||
Query commands print JSON with `--json`.
|
||||
|
||||
```bash
|
||||
pdfs-prompt --dmenu # fuzzel/rofi/wofi/tofi/bemenu/dmenu, whichever is installed
|
||||
pdfs-prompt --dmenu --menu 'fuzzel --dmenu --width 60'
|
||||
pdfs-prompt --dmenu --query invoice # skip straight to a search
|
||||
```
|
||||
## Install
|
||||
|
||||
A launcher filters a fixed list — it cannot ask for a new one per keystroke — so
|
||||
searching takes two steps, and the prompt says which step you are on:
|
||||
|
||||
- `Search Drive ›` lists your pinned files. Type anything that isn't one of them
|
||||
and press Enter to search for it.
|
||||
- `Drive: invoice ›` lists the results. Enter opens one; typing something else
|
||||
and pressing Enter searches again. Escape closes.
|
||||
|
||||
`--query` skips the first step. fuzzel and rofi also get file-type icons.
|
||||
|
||||
To make an existing keybinding use it without re-binding, and to pin the
|
||||
launcher command, set them in `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": {
|
||||
"mode": "dmenu",
|
||||
"menu": ["fuzzel", "--dmenu", "--width", "60"],
|
||||
"menu_limit": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`--gtk` overrides `"mode": "dmenu"` for one invocation. A `{prompt}` token
|
||||
anywhere in `menu` is replaced by the prompt text; without one, the launcher's
|
||||
own prompt flag is appended.
|
||||
|
||||
### Live launcher search (fzf)
|
||||
|
||||
The two-step above exists because no dmenu-style launcher can call back for new
|
||||
results per keystroke. `fzf` can, so `pdfs-prompt --fzf` searches as you type,
|
||||
exactly like the built-in window:
|
||||
|
||||
```bash
|
||||
pdfs-prompt --fzf # pinned files, then live results as you type
|
||||
pdfs-prompt --fzf --query invoice # start on a search
|
||||
```
|
||||
|
||||
Every keystroke re-queries the daemon (debounced, like the GTK prompt) and fzf's
|
||||
own matcher is switched off, so the ordering you see is the daemon's ranking —
|
||||
abbreviations and typos included, which a launcher filtering a fixed list cannot
|
||||
do. Enter opens; Escape closes.
|
||||
|
||||
fzf is a terminal program, so when it is started without one — from a WM
|
||||
keybinding — it spawns a terminal for itself: foot, ghostty, kitty, alacritty,
|
||||
wezterm or xterm, whichever is installed. Each is launched with the app-id/class
|
||||
`pdfs-prompt`, so one window rule floats it whichever terminal you have. In
|
||||
Hyprland:
|
||||
|
||||
```
|
||||
windowrulev2 = float, class:^(pdfs-prompt)$
|
||||
windowrulev2 = size 900 500, class:^(pdfs-prompt)$
|
||||
windowrulev2 = center, class:^(pdfs-prompt)$
|
||||
bind = SUPER, space, exec, pdfs-prompt --fzf
|
||||
```
|
||||
|
||||
Set it as the default front end, and pick the terminal explicitly, in
|
||||
`config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": {
|
||||
"mode": "fzf",
|
||||
"terminal": ["foot", "--app-id=pdfs-prompt", "--"],
|
||||
"menu_limit": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A `{cmd}` token anywhere in `terminal` is replaced by the command to run;
|
||||
without one it is appended, which is what every detected terminal expects.
|
||||
|
||||
## Choosing How Files Open
|
||||
|
||||
By default every result is handed to `xdg-open`. An `open_with` block in
|
||||
`config.json` overrides that per file type — for instance opening text in
|
||||
Neovim inside Alacritty, while everything else still goes to the desktop:
|
||||
|
||||
```json
|
||||
{
|
||||
"open_with": {
|
||||
"terminal": ["alacritty", "-e"],
|
||||
"rules": [
|
||||
{ "match": ["@text"], "command": ["nvim"], "terminal": true },
|
||||
{ "match": ["*.png", "*.jpg"], "command": ["imv"] }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `match` takes file-name globs (`*.md`, `notes-*.txt`) or the classes `@dir`,
|
||||
`@text`, `@document`, `@image`, `@media`, `@any`. First matching rule wins.
|
||||
- `command` is argv, not a shell line. A `{}` token is replaced by the path;
|
||||
without one the path is appended. `$VAR` tokens expand from the environment,
|
||||
so `["$EDITOR"]` follows your editor.
|
||||
- `"terminal": true` wraps the command in `terminal`. When that is unset, the
|
||||
terminal comes from `$PDFS_TERMINAL`, then `$TERMINAL`, then the first known
|
||||
emulator on `PATH`; a bare name like `TERMINAL=alacritty` gains the right
|
||||
"run this" flag automatically.
|
||||
- `"default": [...]` replaces `xdg-open` for everything unmatched.
|
||||
|
||||
The rules apply to the prompt (both front ends) and to the GTK browser, so a
|
||||
file opens the same way wherever it was picked. Patterns are matched against the
|
||||
Drive name — a downloaded file is stored in the cache under its content hash,
|
||||
which says nothing about its type, so matching that would send everything to
|
||||
`xdg-open`.
|
||||
|
||||
## Selective Sync (`.pdfsignore`)
|
||||
|
||||
Two-way synced folders skip paths matched by ignore rules, so syncing a project
|
||||
directory does not upload `node_modules/`, `target/`, or `.git/`.
|
||||
|
||||
Rules come from two places, and both apply:
|
||||
|
||||
1. **Per folder** — a `.pdfsignore` file at the root of the synced folder
|
||||
(`.protonignore` also works). Gitignore syntax, including negation:
|
||||
|
||||
```gitignore
|
||||
# everything build-related
|
||||
build/
|
||||
*.log
|
||||
|
||||
# ...except this one
|
||||
!important.log
|
||||
```
|
||||
|
||||
2. **Globally** — an `ignore_patterns` list in `config.json`, applied to every
|
||||
synced folder. When unset, sensible defaults apply: `.git/`, `.hg/`, `.svn/`,
|
||||
`node_modules/`, `target/`, `.venv/`, `__pycache__/`, `*~`, `*.swp`, `*.tmp`,
|
||||
`.DS_Store`, and `Thumbs.db`.
|
||||
|
||||
```json
|
||||
{
|
||||
"ignore_patterns": ["node_modules/", "target/", "*.iso"]
|
||||
}
|
||||
```
|
||||
|
||||
Set it to `[]` to opt out of the defaults entirely.
|
||||
|
||||
Rules are re-read at the start of every sync pass, so edits take effect on the
|
||||
next pass without restarting the daemon.
|
||||
|
||||
**Ignoring is never destructive.** If a rule starts matching a file that was
|
||||
already synced, its copy on Drive is left untouched — the file simply stops
|
||||
being tracked. Removing the rule later picks the existing remote file back up
|
||||
rather than re-uploading it.
|
||||
|
||||
## Diagnostics & Maintenance
|
||||
|
||||
When something looks wrong, `pdfs diagnose` checks the installation and prints a
|
||||
report. It runs without a daemon on purpose — the state worth diagnosing is
|
||||
usually the state where the daemon will not start:
|
||||
|
||||
```console
|
||||
$ pdfs diagnose
|
||||
Paths
|
||||
[ok ] state dir: /home/you/.local/state/proton-drive-linux
|
||||
[ok ] database: /home/you/.local/state/proton-drive-linux/cache.db (170.3 MiB)
|
||||
|
||||
Account
|
||||
[ok ] keyring session: you@proton.me
|
||||
|
||||
Daemon
|
||||
[ok ] daemon responding
|
||||
[ok ] mounted at: /home/you/ProtonDrive
|
||||
[ok ] queued writes: none
|
||||
|
||||
No problems found.
|
||||
```
|
||||
|
||||
It exits non-zero if any check fails, so it works in a health-check script.
|
||||
|
||||
For the local metadata database and content cache:
|
||||
|
||||
| Command | What it does |
|
||||
| Distribution | Package |
|
||||
|---|---|
|
||||
| `pdfs cache inspect` | Database size, reclaimable space, per-table row counts, cache usage against budget |
|
||||
| `pdfs cache inspect --deep` | Also runs SQLite's integrity check — reads every page, so it is slow on a large database |
|
||||
| `pdfs cache vacuum` | Checkpoints the write-ahead log and compacts the database |
|
||||
| `pdfs cache clear` | Deletes cached file content, keeping pinned files |
|
||||
| Debian, Ubuntu 24.04 or newer | `.deb` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
|
||||
| Fedora 44 or newer | `.rpm` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
|
||||
| Arch Linux | [`proton-drive-for-linux`](https://aur.archlinux.org/packages/proton-drive-for-linux) (also `-bin` and `-git`) in the AUR |
|
||||
| Anything else | [Build from source](docs/INSTALL.md#build-from-source) |
|
||||
|
||||
`vacuum` takes a write lock for its duration and needs room for a second copy of
|
||||
the database while it runs, so it is a deliberate operation rather than
|
||||
something the daemon does on a timer.
|
||||
|
||||
Before a release, run the real-kernel acceptance suite against a dedicated test
|
||||
account and mount. See [`docs/TESTING.md`](docs/TESTING.md); it includes optional
|
||||
two-mount convergence checks and confines destructive operations to a fresh
|
||||
test directory.
|
||||
|
||||
## Scripting (`--json`)
|
||||
|
||||
Query commands accept a global `--json` flag and emit machine-readable output:
|
||||
|
||||
```console
|
||||
$ pdfs --json sync list | jq '.items[] | select(.state != "idle") | .local_path'
|
||||
$ pdfs --json cache inspect | jq '.db_reclaimable_bytes'
|
||||
$ pdfs --json status | jq -r '.mount.mountpoint'
|
||||
```bash
|
||||
sudo apt install ./proton-drive-linux_*.deb # Debian, Ubuntu
|
||||
sudo dnf install ./proton-drive-linux-*.rpm # Fedora
|
||||
paru -S proton-drive-for-linux # Arch
|
||||
```
|
||||
|
||||
Supported on `status`, `ls`, `pins`, `sync list`, `devices list`, `transfers`,
|
||||
`activity`, and `cache inspect`. Commands that perform an action keep their
|
||||
human output — a script that needs to know whether one worked has the exit code.
|
||||
Then open **Proton Drive** from your applications menu and sign in. The app starts the background
|
||||
service, and your files appear in `~/ProtonDrive`.
|
||||
|
||||
Two things worth relying on:
|
||||
Signing in from a terminal instead? `pdfs login` stores the session but does not start the
|
||||
service. Enable it once:
|
||||
|
||||
- **The payload is unwrapped.** Output is `{"items": […]}`, not the daemon's
|
||||
internal `{"SyncFolders": {"items": […]}}`, so scripts never name an internal
|
||||
variant.
|
||||
- **Errors still fail.** A daemon-side error prints its JSON body (with a
|
||||
machine-readable `kind`) on stdout *and* exits non-zero, so `set -e` and
|
||||
`if pdfs …` behave as expected rather than treating a failed lookup as
|
||||
success.
|
||||
```bash
|
||||
pdfs login
|
||||
systemctl --user enable --now proton-drive.service
|
||||
pdfs status
|
||||
```
|
||||
|
||||
## Performance & Caching
|
||||
[docs/INSTALL.md](docs/INSTALL.md) has the requirements, optional extras (tray on GNOME, RAW and
|
||||
video thumbnails), headless use and uninstalling.
|
||||
|
||||
The client includes several optimizations designed for high efficiency, a low memory footprint, and a responsive user experience:
|
||||
## Documentation
|
||||
|
||||
- **On-Demand Block Cache**: Files are read in fixed 4 MiB blocks. For unpinned files, the client fetches and caches only the blocks containing the requested byte ranges. This enables fast sequential and sparse reads (e.g. media streaming or metadata scanning) without downloading entire files.
|
||||
- **Disk-Backed Writes**: Large file writes are staged on disk in temporary scratch files (rather than fully buffered in RAM) and track modified byte intervals. Only the unedited remote segments are fetched at commit time, keeping memory usage minimal.
|
||||
- **Non-Blocking GTK4 Loop**: To prevent UI freezes, all synchronous D-Bus credential checks, control socket requests, and cache usage calculations are offloaded to background worker threads or fetched asynchronously.
|
||||
- **Flicker-Free UI Rendering**: The GTK4 application performs differential rendering of the pins list, only modifying list rows when the list content changes, preserving the user's scroll position and widget focus.
|
||||
- **Durable Staging**: Scratch metadata and staged writes are synced and atomically published before an upload is acknowledged locally. Rapid revisions supersede pending work transactionally.
|
||||
|
||||
## Filesystem Safety
|
||||
|
||||
Version 1.0 strengthens the boundaries around local-only data and destructive reconciliation:
|
||||
|
||||
- Truncate composes with queued revisions, preserving the authoritative prefix and correct zero-filled growth.
|
||||
- Combined move-and-rename operations are queued durably and tolerate partially completed remote state.
|
||||
- Session tombstones prevent successfully unlinked entries from reappearing through eventually consistent listings.
|
||||
- Names are valid UTF-8 and no longer than Linux's 255-byte component limit.
|
||||
- Incomplete mirror scans are non-destructive, and total-wipe protection covers every non-empty baseline.
|
||||
- Failed conflict preservation, local deletion, or staging publication does not settle a new baseline or discard the only local copy.
|
||||
- State, cache, and config directories must be real, current-user-owned `0700` directories; control sockets are `0600`.
|
||||
| | |
|
||||
|---|---|
|
||||
| [User guide](docs/USER_GUIDE.md) | Everything the app, tray and launcher do |
|
||||
| [Command-line reference](docs/CLI.md) | Every `pdfs` command and scripting with `--json` |
|
||||
| [Configuration](docs/CONFIGURATION.md) | `config.json`, file locations, environment variables, the service |
|
||||
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Diagnosing problems and fixing common ones |
|
||||
| [Recovering on a new computer](docs/RECOVERY.md) | Getting your synced folders back after a reinstall or a lost machine |
|
||||
| [Changelog](docs/CHANGELOG.md) | What changed in each release |
|
||||
|
||||
For contributors: [development guide](docs/DEVELOPMENT.md), [architecture](docs/ARCHITECTURE.md),
|
||||
[testing](docs/TESTING.md), [translating](docs/TRANSLATING.md), [roadmap](docs/ROADMAP.md) and the
|
||||
[bug ledger](docs/BUGS.md). The [documentation index](docs/README.md) lists everything.
|
||||
|
||||
## Screenshots
|
||||
|
||||
### GUI Application & Launcher
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%"><img src="images/login.png" alt="Login Screen" width="100%"><br><sub><b>Login Screen</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/files.png" alt="Files Browser" width="100%"><br><sub><b>Files Browser</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/files.png" alt="My files" width="100%"><br><sub><b>My files</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/photos.png" alt="Photos timeline" width="100%"><br><sub><b>Photos</b></sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="50%"><img src="images/shared.png" alt="Shared Links" width="100%"><br><sub><b>Shared Links</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/shared_with_me.png" alt="Shared with me" width="100%"><br><sub><b>Shared with me</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/photos_map.png" alt="Photos places map" width="100%"><br><sub><b>Places</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/computers.png" alt="Computers" width="100%"><br><sub><b>Computers</b></sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="50%"><img src="images/restore_devices.png" alt="Local Backups (Computers)" width="100%"><br><sub><b>Local Backups (Computers)</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/photos.png" alt="Photos Timeline" width="100%"><br><sub><b>Photos Timeline</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/sync.png" alt="Sync folders" width="100%"><br><sub><b>Sync</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/conflicts.png" alt="Sync history showing resolved conflicts" width="100%"><br><sub><b>Sync history</b></sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="50%"><img src="images/prompt_gtk.png" alt="Search Launcher Prompt (GTK)" width="100%"><br><sub><b>Search Launcher Prompt (GTK)</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/prompt_fzf.png" alt="Search Launcher Prompt (fzf)" width="100%"><br><sub><b>Search Launcher Prompt (fzf)</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/prompt_gtk.png" alt="Search launcher" width="100%"><br><sub><b>Search launcher</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/prompt_fzf.png" alt="Search launcher in fzf" width="100%"><br><sub><b>Search launcher in fzf</b></sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="50%"><img src="images/activity.png" alt="Activity" width="100%"><br><sub><b>Activity</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/settings.png" alt="Settings" width="100%"><br><sub><b>Settings</b></sub></td>
|
||||
<td align="center" width="50%"><img src="images/login.png" alt="Sign in" width="100%"><br><sub><b>Sign in</b></sub></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
## How it works
|
||||
|
||||
## Prerequisites
|
||||
A background service (`proton-drive.service`, the `pdfs daemon` command) holds your session,
|
||||
mounts `~/ProtonDrive` through FUSE, runs the upload queue and the sync engine, and follows
|
||||
Proton's event stream for changes made elsewhere. The app, the tray, the launcher and the CLI are
|
||||
clients of that service over a local socket. Everything is end-to-end encrypted with your Proton
|
||||
keys before it leaves the machine, through a Rust implementation of Proton's Drive SDK.
|
||||
|
||||
To compile the application from source or run the built binaries, ensure you have the following system libraries installed on your distribution:
|
||||
|
||||
### Ubuntu / Debian (24.04+)
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
pkg-config \
|
||||
libfuse3-dev \
|
||||
libgtk-4-dev \
|
||||
libadwaita-1-dev \
|
||||
libwebkitgtk-6.0-dev \
|
||||
libsecret-1-dev \
|
||||
libdbus-1-dev \
|
||||
libimage-exiftool-perl
|
||||
```
|
||||
|
||||
### Arch Linux
|
||||
```bash
|
||||
sudo pacman -S --needed pkgconf fuse3 gtk4 libadwaita libsecret dbus webkitgtk-6.0 perl-image-exiftool
|
||||
```
|
||||
|
||||
### Fedora (44+)
|
||||
```bash
|
||||
sudo dnf install -y \
|
||||
pkgconf-pkg-config fuse3-devel gtk4-devel libadwaita-devel \
|
||||
webkitgtk6.0-devel libsecret-devel dbus-devel glib2-devel \
|
||||
perl-Image-ExifTool cargo rust
|
||||
```
|
||||
|
||||
Runtime extras (pick your desktop):
|
||||
```bash
|
||||
# GNOME — keyring + tray (AppIndicator)
|
||||
sudo dnf install -y gnome-keyring gnome-shell-extension-appindicator xdg-utils
|
||||
|
||||
# KDE Plasma — KWallet (Secret Service); tray works via built-in SNI
|
||||
sudo dnf install -y kwallet xdg-utils
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Building from Source
|
||||
|
||||
Ensure you have Rust and Cargo installed (minimum supported Rust version is 1.96).
|
||||
|
||||
1. Clone the repository and navigate into the project directory:
|
||||
```bash
|
||||
git clone https://github.com/narrrl/proton-drive-linux.git
|
||||
cd proton-drive-linux
|
||||
```
|
||||
2. Build the workspace in release mode:
|
||||
```bash
|
||||
cargo build --release --locked
|
||||
```
|
||||
|
||||
The compiled binaries will be available under `target/release/`:
|
||||
- `pdfs`: The CLI utility.
|
||||
- `pdfs-app`: The GTK4 application.
|
||||
- `pdfs-tray`: The tray status notifier.
|
||||
- `pdfs-prompt`: The launcher prompt for quick HUD search.
|
||||
|
||||
---
|
||||
|
||||
## Installation & Packages
|
||||
|
||||
### 1. Debian / Ubuntu (.deb)
|
||||
Install the debian package via `dpkg` or `apt`:
|
||||
```bash
|
||||
sudo apt install ./proton-drive-linux_*.deb
|
||||
```
|
||||
|
||||
### 2. Arch Linux (AUR)
|
||||
Three AUR packages cover the usual choices; they conflict with each other, so
|
||||
install one:
|
||||
|
||||
| Package | Contents |
|
||||
| --- | --- |
|
||||
| [`proton-drive-for-linux`](https://aur.archlinux.org/packages/proton-drive-for-linux) | builds the latest tagged release from source |
|
||||
| [`proton-drive-for-linux-git`](https://aur.archlinux.org/packages/proton-drive-for-linux-git) | builds `main` from git |
|
||||
| [`proton-drive-for-linux-bin`](https://aur.archlinux.org/packages/proton-drive-for-linux-bin) | installs the release binaries, no compile |
|
||||
|
||||
```bash
|
||||
paru -S proton-drive-for-linux # or any AUR helper
|
||||
```
|
||||
|
||||
The package sources live in `packaging/aur/`; see
|
||||
[`packaging/aur/README.md`](packaging/aur/README.md) for how they are updated and
|
||||
published. A local `PKGBUILD` that compiles the working tree in place is still
|
||||
available for development builds:
|
||||
```bash
|
||||
cd packaging && makepkg -fi
|
||||
```
|
||||
|
||||
### 3. Fedora (local RPM)
|
||||
A local `.spec` is available under `packaging/`. From the repository root:
|
||||
```bash
|
||||
sudo dnf install -y rpm-build
|
||||
rpmbuild -bb packaging/proton-drive-linux.spec \
|
||||
--define "git_dir $PWD" \
|
||||
--define "_rpmdir $PWD/packaging/out" \
|
||||
--define "_builddir $PWD/packaging/build" \
|
||||
--define "_sourcedir $PWD" \
|
||||
--define "_specdir $PWD/packaging" \
|
||||
--define "_srcrpmdir $PWD/packaging/out"
|
||||
sudo dnf install packaging/out/x86_64/proton-drive-linux-*.rpm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Automated Releases (CI/CD)
|
||||
|
||||
This project has a GitHub Actions CI workflow configured under `.github/workflows/release.yml`.
|
||||
|
||||
### How it works:
|
||||
1. **Triggers**:
|
||||
- Pushing a git tag matching `v*` (e.g. `git tag v0.1.0 && git push origin v0.1.0`).
|
||||
- Manual runs via the **Actions** tab in GitHub (**workflow_dispatch**).
|
||||
2. **Quality Gates and Build Process**:
|
||||
- For tagged builds, verifies that the tag, workspace version, and `packaging/PKGBUILD` version agree.
|
||||
- Runs `cargo fmt`, Clippy with warnings denied, the locked workspace test suite, and the account-free FUSE acceptance contract.
|
||||
- Spawns an Ubuntu runner and installs GTK4, Libadwaita, FUSE3, and Secret Service packages.
|
||||
- Sets up the Rust compiler and caches build targets to speed up runs.
|
||||
- Compiles the workspace members in release mode.
|
||||
3. **Artifact Packaging**:
|
||||
- Generates a `.tar.gz` containing the raw binaries (`pdfs`, `pdfs-app`, `pdfs-tray`, `pdfs-prompt`).
|
||||
- Packs them into Debian (`.deb`) and Fedora (`.rpm`) packages.
|
||||
- Includes the systemd user service and tray autostart entry in the Debian package.
|
||||
4. **Publishing**:
|
||||
- Creates a GitHub Release matching the pushed tag and uploads the `.deb`, `.rpm`, and `.tar.gz` packages as release assets.
|
||||
- For manual runs, compiles and exposes the packages as workflow run artifacts for testing.
|
||||
|
||||
---
|
||||
Files you open are decrypted into a local cache so programs can read them. What that means for
|
||||
the security of the machine is spelled out in the
|
||||
[threat model](docs/ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||
|
||||
## Contributing
|
||||
|
||||
Bug reports, packaging fixes, and patches are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for
|
||||
the workflow and the quality gates CI enforces, and [SECURITY.md](SECURITY.md) for reporting a
|
||||
vulnerability privately.
|
||||
|
||||
The app, tray and search prompt are available in English, German, Spanish, French, Italian, Dutch,
|
||||
Polish and Brazilian Portuguese. [docs/TRANSLATING.md](docs/TRANSLATING.md) explains how to improve
|
||||
a translation or add a language.
|
||||
Bug reports, translations and patches are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the
|
||||
workflow and the checks CI runs, and [SECURITY.md](SECURITY.md) to report a vulnerability
|
||||
privately.
|
||||
|
||||
## License
|
||||
|
||||
Released under the [MIT License](LICENSE).
|
||||
|
||||
The Places map uses [GeoNames](https://www.geonames.org/) data (CC BY 4.0) and
|
||||
[Natural Earth](https://www.naturalearthdata.com/) outlines (public domain). The optional street
|
||||
map comes from [OpenFreeMap](https://openfreemap.org/), © [OpenStreetMap](https://www.openstreetmap.org/copyright)
|
||||
contributors.
|
||||
|
||||
Proton Drive is a service of Proton AG. This project is an independent client and carries no
|
||||
affiliation with or endorsement from Proton AG.
|
||||
@@ -30,5 +30,10 @@ the interesting boundaries are:
|
||||
- **Cryptography.** Key derivation, signature handling, and anything that could publish plaintext
|
||||
or the wrong key material to Proton.
|
||||
|
||||
What the client deliberately keeps in plaintext on disk, and why, is documented in the
|
||||
[threat model](docs/ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||
Behaviour described there is known and not a vulnerability by itself; a way to make it worse
|
||||
than described is.
|
||||
|
||||
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.
|
||||
@@ -30,7 +30,7 @@ struct Cli {
|
||||
/// Emit machine-readable JSON instead of formatted text.
|
||||
///
|
||||
/// Applies to the query commands (`status`, `ls`, `pins`, `sync list`,
|
||||
/// `devices list`, `locations`, `transfers`, `activity`, `cache inspect`). Commands that
|
||||
/// `devices list`, `locations`, `transfers`, `activity`, `trash`, `cache inspect`). Commands that
|
||||
/// perform an action keep their human output — a script that needs to know
|
||||
/// whether one succeeded has the exit code.
|
||||
#[arg(long, global = true)]
|
||||
@@ -290,12 +290,17 @@ enum Command {
|
||||
/// New name (a single path component).
|
||||
new_name: String,
|
||||
},
|
||||
/// Move a file or folder into another folder via the running daemon.
|
||||
/// Move files or folders into another folder via the running daemon.
|
||||
///
|
||||
/// The paths may be in different locations: My files and any synced
|
||||
/// folder, on-demand or mirrored. The move happens on Proton Drive, so
|
||||
/// nothing is downloaded or uploaded again, unlike `mv` between two
|
||||
/// locations. The last path is the destination folder.
|
||||
Move {
|
||||
/// File/folder path, inside the mountpoint or relative to it.
|
||||
path: PathBuf,
|
||||
/// Destination folder path, inside the mountpoint or relative to it.
|
||||
new_parent: PathBuf,
|
||||
/// Files/folders to move, then the destination folder: each inside a
|
||||
/// location, or relative to the mountpoint.
|
||||
#[arg(required = true, num_args = 2.., value_name = "PATH")]
|
||||
paths: Vec<PathBuf>,
|
||||
},
|
||||
/// Trash a file or folder via the running daemon.
|
||||
Rm {
|
||||
@@ -800,7 +805,10 @@ fn main() -> Result<()> {
|
||||
scope,
|
||||
} => cmd_search(query, limit, scope),
|
||||
Command::Rename { path, new_name } => cmd_rename(path, new_name),
|
||||
Command::Move { path, new_parent } => cmd_move(path, new_parent),
|
||||
Command::Move { mut paths } => {
|
||||
let new_parent = paths.pop().expect("clap requires at least two paths");
|
||||
cmd_move(paths, new_parent)
|
||||
}
|
||||
Command::Rm { path } => cmd_rm(path),
|
||||
Command::Mkdir { parent, name } => cmd_mkdir(parent, name),
|
||||
Command::Upload { sources, parent } => cmd_upload(sources, parent),
|
||||
@@ -2439,14 +2447,33 @@ fn cmd_rename(path: PathBuf, new_name: String) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_move(path: PathBuf, new_parent: PathBuf) -> Result<()> {
|
||||
match control_request(CtlRequest::Move {
|
||||
path: path_arg(&path)?,
|
||||
new_parent: path_arg(&new_parent)?,
|
||||
})? {
|
||||
CtlResponse::Ok { message } => println!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
/// Move each of `sources` into `new_parent`, one request each. A refusal names
|
||||
/// its source and the rest still move; the command fails if any did not.
|
||||
fn cmd_move(sources: Vec<PathBuf>, new_parent: PathBuf) -> Result<()> {
|
||||
let new_parent = path_arg(&new_parent)?;
|
||||
let mut failed = 0;
|
||||
for source in &sources {
|
||||
let outcome = path_arg(source).and_then(|path| {
|
||||
match control_request(CtlRequest::Move {
|
||||
path,
|
||||
new_parent: new_parent.clone(),
|
||||
})? {
|
||||
CtlResponse::Ok { message } => Ok(message),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
});
|
||||
match outcome {
|
||||
Ok(message) => println!("{message}"),
|
||||
Err(e) if sources.len() == 1 => return Err(e),
|
||||
Err(e) => {
|
||||
eprintln!("{}: {e:#}", source.display());
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if failed > 0 {
|
||||
bail!("{failed} of {} could not be moved", sources.len());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -2524,7 +2551,11 @@ fn cmd_diagnostics() -> Result<()> {
|
||||
}
|
||||
|
||||
fn cmd_trash() -> Result<()> {
|
||||
match control_request(CtlRequest::ListTrash)? {
|
||||
let response = control_request(CtlRequest::ListTrash)?;
|
||||
if emit_json(&response)? {
|
||||
return Ok(());
|
||||
}
|
||||
match response {
|
||||
CtlResponse::Entries { entries } if entries.is_empty() => println!("(trash is empty)"),
|
||||
CtlResponse::Entries { entries } => {
|
||||
for e in entries {
|
||||
@@ -3364,6 +3395,8 @@ mod login_wait_tests {
|
||||
shared_by: String::new(),
|
||||
shared_at: 0,
|
||||
shared_by_unverified: false,
|
||||
trashed_at: 0,
|
||||
trashed_from: None,
|
||||
};
|
||||
assert_eq!(shared_by_line(&entry), None, "no invitation, no line");
|
||||
entry.shared_by = "alice@proton.me".into();
|
||||
|
||||
@@ -730,6 +730,60 @@ impl ContentCache {
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
/// Write the whole content of `uid` at `(mtime, size)` to `dst` from the
|
||||
/// cache alone, and report whether that worked.
|
||||
///
|
||||
/// A whole-file blob is cloned. Otherwise every block of the revision has to
|
||||
/// be cached, under its recorded geometry or, failing that, the uniform one
|
||||
/// the read path would use. Anything short of the full content leaves
|
||||
/// nothing at `dst`, so a caller falls back to a download with a clean slate.
|
||||
///
|
||||
/// This is what lets a mirror folder take a file an on-demand mount has
|
||||
/// already read, after a move between them, without fetching it again. The
|
||||
/// trust is the read path's: the same `(mtime, size)` key, and blocks that
|
||||
/// were validated when they were fetched.
|
||||
pub fn copy_cached_to(&self, uid: &NodeUid, mtime: i64, size: u64, dst: &Path) -> bool {
|
||||
if size == 0 {
|
||||
return false;
|
||||
}
|
||||
if let Some(blob) = self.valid_blob(uid, mtime, size) {
|
||||
if Self::clone_or_copy(&blob, dst).is_ok_and(|len| len == size) {
|
||||
self.touch(Self::key(uid));
|
||||
return true;
|
||||
}
|
||||
let _ = std::fs::remove_file(dst);
|
||||
return false;
|
||||
}
|
||||
let geometry = self
|
||||
.block_geometry(uid, mtime, size)
|
||||
.unwrap_or_else(|| BlockGeometry::uniform(size));
|
||||
if geometry.size() != size {
|
||||
return false;
|
||||
}
|
||||
let spans = geometry.spans(0, size);
|
||||
// Checked up front so a file with one block missing costs stats, not a
|
||||
// copy of every block before it.
|
||||
if !spans
|
||||
.iter()
|
||||
.all(|&span| self.has_block(uid, mtime, size, span))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let written = (|| -> Option<()> {
|
||||
use std::io::Write;
|
||||
let mut out = std::fs::File::create(dst).ok()?;
|
||||
for span in spans {
|
||||
out.write_all(&self.cached_block(uid, mtime, size, span)?)
|
||||
.ok()?;
|
||||
}
|
||||
(out.metadata().ok()?.len() == size).then_some(())
|
||||
})();
|
||||
if written.is_none() {
|
||||
let _ = std::fs::remove_file(dst);
|
||||
}
|
||||
written.is_some()
|
||||
}
|
||||
|
||||
/// Store `bytes` as the cached content for `uid`, tagged with `(mtime,
|
||||
/// size)`. The blob is written to a temp file then renamed so a concurrent
|
||||
/// reader never sees a partial blob; the meta tag is written last so a
|
||||
@@ -2561,6 +2615,46 @@ mod tests {
|
||||
assert!(c.cached_block(&u, 100, 5000, blk(5000, 0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cached_blob_is_copied_out_whole() {
|
||||
let (c, d) = cache();
|
||||
let u = uid("blob");
|
||||
c.store(&u, 7, 5, b"hello").unwrap();
|
||||
let dst = d.path().join("out");
|
||||
assert!(c.copy_cached_to(&u, 7, 5, &dst));
|
||||
assert_eq!(std::fs::read(&dst).unwrap(), b"hello");
|
||||
// Another revision is not this one.
|
||||
assert!(!c.copy_cached_to(&u, 8, 5, &d.path().join("stale")));
|
||||
assert!(!d.path().join("stale").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_blocks_are_copied_out_in_order() {
|
||||
let (c, d) = cache();
|
||||
let u = uid("blocks");
|
||||
let size = BLOCK_SIZE + 3;
|
||||
let first = vec![1_u8; BLOCK_SIZE as usize];
|
||||
c.store_block(&u, 7, size, blk(size, 0), &first).unwrap();
|
||||
c.store_block(&u, 7, size, blk(size, 1), b"end").unwrap();
|
||||
let dst = d.path().join("out");
|
||||
assert!(c.copy_cached_to(&u, 7, size, &dst));
|
||||
let bytes = std::fs::read(&dst).unwrap();
|
||||
assert_eq!(bytes.len() as u64, size);
|
||||
assert_eq!(&bytes[..BLOCK_SIZE as usize], first.as_slice());
|
||||
assert_eq!(&bytes[BLOCK_SIZE as usize..], b"end");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_a_block_missing_is_not_copied_out() {
|
||||
let (c, d) = cache();
|
||||
let u = uid("partial");
|
||||
let size = BLOCK_SIZE + 3;
|
||||
c.store_block(&u, 7, size, blk(size, 1), b"end").unwrap();
|
||||
let dst = d.path().join("out");
|
||||
assert!(!c.copy_cached_to(&u, 7, size, &dst));
|
||||
assert!(!dst.exists(), "a partial copy must not be left behind");
|
||||
}
|
||||
|
||||
/// The [`BlockSpan`] the uniform geometry gives block `idx` of a file of
|
||||
/// `size` bytes — the shape these tests were written against, and the one
|
||||
/// this client's own uploads still have.
|
||||
|
||||
@@ -31,6 +31,13 @@ const WRITE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
/// answers.
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Cap on the read half for the requests front ends poll ([`Request::is_poll`]).
|
||||
/// The daemon answers those from memory or its database, so a reply that takes
|
||||
/// longer than this means it is stuck. Waiting the full [`READ_TIMEOUT`] would
|
||||
/// freeze the poller's read-out for two minutes; failing fast lets it say "not
|
||||
/// responding" and ask again on its next tick.
|
||||
const POLL_READ_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// A command sent from the CLI to the daemon.
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub enum Request {
|
||||
@@ -332,7 +339,10 @@ pub enum Request {
|
||||
/// single path component (no separators). Replies with [`Response::Ok`].
|
||||
Rename { path: String, new_name: String },
|
||||
/// Move a file or folder into a new parent folder. Both `path` and
|
||||
/// `new_parent` are mountpoint-relative. Replies with [`Response::Ok`].
|
||||
/// `new_parent` are mountpoint-relative, or both absolute local paths; two
|
||||
/// absolute paths may lie in different locations (My files, an on-demand
|
||||
/// or a mirror synced folder), and the move is still made on Proton Drive
|
||||
/// without transferring content. Replies with [`Response::Ok`].
|
||||
Move { path: String, new_parent: String },
|
||||
/// Trash a file or folder. `path` is mountpoint-relative. Replies with
|
||||
/// [`Response::Ok`].
|
||||
@@ -672,8 +682,88 @@ pub enum Request {
|
||||
/// passes, …), so a front-end can show a running "what happened" feed
|
||||
/// without re-deriving it from anywhere.
|
||||
ListActivity { limit: usize },
|
||||
|
||||
// ---- push -------------------------------------------------------------
|
||||
/// Keep the connection open and stream [`Event`] lines instead of one
|
||||
/// [`Response`]. See [`subscribe`] for the client side.
|
||||
Subscribe,
|
||||
}
|
||||
|
||||
/// Something a front-end shows that the daemon can say has changed, so the
|
||||
/// front-end reads it again. Carried by [`Event::Changed`].
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Topic {
|
||||
/// Folder listings in the mount: something was created, renamed, moved,
|
||||
/// trashed or pinned, here or on another device.
|
||||
Files,
|
||||
/// The photos timeline or albums.
|
||||
Photos,
|
||||
Trash,
|
||||
/// Shares, links, invitations and saved links, in either direction.
|
||||
Shares,
|
||||
/// The account's computers.
|
||||
Devices,
|
||||
/// Synced folders, their mode and their live progress.
|
||||
Locations,
|
||||
/// The pending-op queue ([`Request::ListPendingOps`]).
|
||||
Queue,
|
||||
Conflicts,
|
||||
/// The activity log gained entries.
|
||||
Activity,
|
||||
/// A topic from a newer daemon, which this front-end cannot act on.
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Topic {
|
||||
/// Every topic a front-end can act on. A new subscription is told all of
|
||||
/// them have changed, so it catches up on whatever happened while it was
|
||||
/// not connected.
|
||||
pub const ALL: [Topic; 9] = [
|
||||
Topic::Files,
|
||||
Topic::Photos,
|
||||
Topic::Trash,
|
||||
Topic::Shares,
|
||||
Topic::Devices,
|
||||
Topic::Locations,
|
||||
Topic::Queue,
|
||||
Topic::Conflicts,
|
||||
Topic::Activity,
|
||||
];
|
||||
}
|
||||
|
||||
/// One line on a [`Request::Subscribe`] connection.
|
||||
///
|
||||
/// The two things front ends show continuously, status and transfers, come
|
||||
/// with their contents, so following them costs no requests. Everything else is
|
||||
/// a [`Topic`] to re-read.
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub enum Event {
|
||||
/// A fresh [`Response::Status`]. Sent when a subscription starts and
|
||||
/// whenever any of its fields changes.
|
||||
Status(Box<Response>),
|
||||
/// A fresh [`Response::Transfers`] snapshot. Sent when a subscription
|
||||
/// starts and whenever the snapshot changes, which is continuously while
|
||||
/// something moves.
|
||||
Transfers {
|
||||
items: Vec<TransferItem>,
|
||||
jobs: Vec<JobItem>,
|
||||
},
|
||||
/// These topics changed. Changes close together arrive as one event.
|
||||
Changed { topics: Vec<Topic> },
|
||||
/// Nothing happened. Sent every [`HEARTBEAT`] so a client can tell a quiet
|
||||
/// daemon from a stuck one.
|
||||
Heartbeat,
|
||||
}
|
||||
|
||||
/// How often a quiet subscription sends [`Event::Heartbeat`].
|
||||
pub const HEARTBEAT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// How long [`Subscription::next_event`] waits for a line before it gives the
|
||||
/// daemon up as stuck: a few missed heartbeats.
|
||||
const SUBSCRIBE_READ_TIMEOUT: Duration = Duration::from_secs(25);
|
||||
|
||||
/// Which kind of share entry a [`ShareEntry`] is, and which collection an
|
||||
/// [`Request::UpdateShareRole`]/[`Request::RemoveShareEntry`] targets.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -739,7 +829,7 @@ pub struct DeviceInfo {
|
||||
|
||||
/// One `(sync-conflict …)` copy and the file it is a copy of (in
|
||||
/// [`Response::Conflicts`]).
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct ConflictInfo {
|
||||
/// Mountpoint-relative path of the copy, or its absolute local path when it
|
||||
/// sits in a synced folder.
|
||||
@@ -898,7 +988,7 @@ pub struct ShareEntry {
|
||||
}
|
||||
|
||||
/// A node's public link (in [`Response::Share`] / [`Response::PublicLink`]).
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct PublicLinkInfo {
|
||||
/// Public-link id — the handle for [`Request::RemovePublicLink`].
|
||||
pub id: String,
|
||||
@@ -911,6 +1001,9 @@ pub struct PublicLinkInfo {
|
||||
pub expires: Option<i64>,
|
||||
/// Whether a custom password additionally protects the link.
|
||||
pub has_password: bool,
|
||||
/// When the link was made, epoch seconds; 0 when unknown.
|
||||
#[serde(default)]
|
||||
pub created: i64,
|
||||
}
|
||||
|
||||
/// One invitation addressed to me (in [`Response::Invitations`]).
|
||||
@@ -943,7 +1036,7 @@ pub struct BookmarkInfo {
|
||||
/// the share's state so the "Shared" view renders in one pass: how many people
|
||||
/// have access, how many invitations are still pending, and the public link if
|
||||
/// the node has one.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct SharedItem {
|
||||
/// Node uid in `volume~link` form — the handle for opening the node's share
|
||||
/// (via its mount path) or its details.
|
||||
@@ -1025,7 +1118,7 @@ pub enum ActivityKind {
|
||||
/// One line in the daemon's activity log (in [`Response::Activity`]). Newest
|
||||
/// first. Records a mutation or transfer the daemon performed, with enough
|
||||
/// context to read as a sentence: "Uploaded report.pdf to /docs".
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct ActivityEntry {
|
||||
/// When it happened, epoch seconds.
|
||||
pub time: i64,
|
||||
@@ -1083,7 +1176,7 @@ pub struct TransferItem {
|
||||
}
|
||||
|
||||
/// One entry in a [`Request::ListDir`] listing.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
pub struct DirEntry {
|
||||
/// Decrypted node name.
|
||||
pub name: String,
|
||||
@@ -1131,6 +1224,16 @@ pub struct DirEntry {
|
||||
/// warning worth showing.
|
||||
#[serde(default)]
|
||||
pub shared_by_unverified: bool,
|
||||
/// When a [`Request::ListTrash`] entry went to the trash, epoch seconds; 0
|
||||
/// when unknown, and for anything not in the trash. The API does not report
|
||||
/// it to this client, so the daemon notes when it first saw the node there.
|
||||
#[serde(default)]
|
||||
pub trashed_at: i64,
|
||||
/// The folder a [`Request::ListTrash`] entry goes back to on restore, as a
|
||||
/// mountpoint-relative path (`""` for the root). `None` when that folder is
|
||||
/// not known, and for anything not in the trash.
|
||||
#[serde(default)]
|
||||
pub trashed_from: Option<String>,
|
||||
}
|
||||
|
||||
/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
|
||||
@@ -2046,13 +2149,45 @@ impl ErrorKind {
|
||||
}
|
||||
}
|
||||
|
||||
impl Request {
|
||||
/// Whether this is one of the cheap state reads that front ends repeat on a
|
||||
/// timer. The daemon answers these without touching the network, so they
|
||||
/// get the short [`POLL_READ_TIMEOUT`].
|
||||
pub fn is_poll(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Request::Status
|
||||
| Request::GetQueueStatus
|
||||
| Request::ListLocations
|
||||
| Request::ListPendingOps
|
||||
| Request::ListConflicts
|
||||
| Request::ListActivity { .. }
|
||||
| Request::ThumbnailBuildStatus
|
||||
| Request::PhotosRefreshStatus
|
||||
| Request::RedateStatus
|
||||
| Request::ImportStatus
|
||||
)
|
||||
}
|
||||
|
||||
/// How long to wait for the daemon's reply to this request.
|
||||
pub fn read_timeout(&self) -> Duration {
|
||||
if self.is_poll() {
|
||||
POLL_READ_TIMEOUT
|
||||
} else {
|
||||
READ_TIMEOUT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one [`Request`] to the daemon listening on `socket` and read its
|
||||
/// [`Response`]. Returns a crate [`crate::Error`] if no daemon is listening.
|
||||
/// [`Response`]. Returns a crate [`crate::Error`] if no daemon is listening, and
|
||||
/// one for which [`crate::Error::is_timeout`] holds if the daemon took longer
|
||||
/// than [`Request::read_timeout`] to answer.
|
||||
///
|
||||
/// Shared by the CLI and GUI so both speak the wire format identically.
|
||||
pub fn send(socket: &Path, req: &Request) -> Result<Response> {
|
||||
let mut stream = UnixStream::connect(socket)?;
|
||||
stream.set_read_timeout(Some(READ_TIMEOUT))?;
|
||||
stream.set_read_timeout(Some(req.read_timeout()))?;
|
||||
stream.set_write_timeout(Some(WRITE_TIMEOUT))?;
|
||||
let mut line = serde_json::to_vec(req)?;
|
||||
line.push(b'\n');
|
||||
@@ -2064,10 +2199,232 @@ pub fn send(socket: &Path, req: &Request) -> Result<Response> {
|
||||
Ok(serde_json::from_str(resp.trim())?)
|
||||
}
|
||||
|
||||
/// An open [`Request::Subscribe`] connection. See [`subscribe`].
|
||||
pub struct Subscription {
|
||||
reader: BufReader<UnixStream>,
|
||||
line: String,
|
||||
}
|
||||
|
||||
/// Start following the daemon listening on `socket`. Fails if no daemon is
|
||||
/// listening. A daemon too old to know [`Request::Subscribe`] answers it with an
|
||||
/// error, which the first [`Subscription::next_event`] returns as
|
||||
/// [`crate::Error::Other`].
|
||||
pub fn subscribe(socket: &Path) -> Result<Subscription> {
|
||||
let mut stream = UnixStream::connect(socket)?;
|
||||
stream.set_read_timeout(Some(SUBSCRIBE_READ_TIMEOUT))?;
|
||||
stream.set_write_timeout(Some(WRITE_TIMEOUT))?;
|
||||
let mut line = serde_json::to_vec(&Request::Subscribe)?;
|
||||
line.push(b'\n');
|
||||
stream.write_all(&line)?;
|
||||
stream.flush()?;
|
||||
Ok(Subscription {
|
||||
reader: BufReader::new(stream),
|
||||
line: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
impl Subscription {
|
||||
/// Wait for the next event. `Ok(None)` means the daemon closed the
|
||||
/// connection; a timeout means it went [`SUBSCRIBE_READ_TIMEOUT`] without
|
||||
/// even a heartbeat. A line from a newer daemon that this client cannot
|
||||
/// read is skipped rather than ending the subscription.
|
||||
pub fn next_event(&mut self) -> Result<Option<Event>> {
|
||||
loop {
|
||||
self.line.clear();
|
||||
if self.reader.read_line(&mut self.line)? == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let line = self.line.trim();
|
||||
if let Ok(event) = serde_json::from_str::<Event>(line) {
|
||||
return Ok(Some(event));
|
||||
}
|
||||
if let Ok(Response::Error { message, .. }) = serde_json::from_str::<Response>(line) {
|
||||
return Err(crate::Error::Other(message));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`follow`] reports.
|
||||
#[derive(Debug)]
|
||||
pub enum Feed {
|
||||
/// A subscription started. The events after it describe the daemon's
|
||||
/// current state, so a front-end catches up from them.
|
||||
Connected,
|
||||
Event(Event),
|
||||
/// The subscription ended: the daemon stopped, or stopped answering.
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
/// How often [`follow`] polls a daemon that cannot push events.
|
||||
const LEGACY_POLL: Duration = Duration::from_secs(3);
|
||||
|
||||
/// The longest [`follow`] waits between attempts to reach a daemon.
|
||||
const RECONNECT_MAX: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Follow the daemon on `socket` for as long as `on` returns true, across
|
||||
/// daemon restarts. Blocks, so run it on a thread of its own.
|
||||
///
|
||||
/// A daemon that predates [`Request::Subscribe`] (still running after an
|
||||
/// upgrade, until its service restarts) is polled for its status and transfers
|
||||
/// instead, so a front-end sees the same events either way, only later.
|
||||
pub fn follow(socket: &Path, mut on: impl FnMut(Feed) -> bool) {
|
||||
let mut connected = false;
|
||||
let mut backoff = Duration::from_secs(1);
|
||||
loop {
|
||||
let ended = match subscribe(socket) {
|
||||
Ok(mut subscription) => {
|
||||
let mut first = true;
|
||||
loop {
|
||||
match subscription.next_event() {
|
||||
Ok(Some(event)) => {
|
||||
if first {
|
||||
first = false;
|
||||
connected = true;
|
||||
backoff = Duration::from_secs(1);
|
||||
if !on(Feed::Connected) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if !on(Feed::Event(event)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Ok(None) => break Ok(()),
|
||||
Err(e) => break Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
if let Err(crate::Error::Other(_)) = ended {
|
||||
if !connected {
|
||||
connected = true;
|
||||
if !on(Feed::Connected) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if !poll_legacy(socket, &mut on) {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(LEGACY_POLL);
|
||||
continue;
|
||||
}
|
||||
if connected {
|
||||
connected = false;
|
||||
if !on(Feed::Disconnected) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
std::thread::sleep(backoff);
|
||||
backoff = (backoff * 2).min(RECONNECT_MAX);
|
||||
}
|
||||
}
|
||||
|
||||
/// One round of [`follow`]'s fallback: the status and transfers a subscription
|
||||
/// would have pushed. Returns what `on` returned.
|
||||
fn poll_legacy(socket: &Path, on: &mut impl FnMut(Feed) -> bool) -> bool {
|
||||
match send(socket, &Request::Status) {
|
||||
Ok(status @ Response::Status { .. }) => {
|
||||
if !on(Feed::Event(Event::Status(Box::new(status)))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_ => return true,
|
||||
}
|
||||
if let Ok(Response::Transfers { items, jobs }) = send(socket, &Request::GetQueueStatus) {
|
||||
return on(Feed::Event(Event::Transfers { items, jobs }));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn polls_wait_briefly_and_real_work_waits_long() {
|
||||
assert_eq!(Request::Status.read_timeout(), POLL_READ_TIMEOUT);
|
||||
assert_eq!(
|
||||
Request::ListActivity { limit: 200 }.read_timeout(),
|
||||
POLL_READ_TIMEOUT
|
||||
);
|
||||
assert_eq!(
|
||||
Request::OpenFile {
|
||||
path: "a.txt".into(),
|
||||
uid: None,
|
||||
}
|
||||
.read_timeout(),
|
||||
READ_TIMEOUT
|
||||
);
|
||||
}
|
||||
|
||||
/// A daemon on a fresh socket that reads the subscription request and
|
||||
/// answers with `lines`, then hangs up.
|
||||
fn fake_daemon(name: &str, lines: &'static str) -> std::path::PathBuf {
|
||||
let socket = std::env::temp_dir().join(format!("pdfs-{name}-{}.sock", std::process::id()));
|
||||
let _ = std::fs::remove_file(&socket);
|
||||
let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
|
||||
std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut request = String::new();
|
||||
reader.read_line(&mut request).unwrap();
|
||||
assert!(matches!(
|
||||
serde_json::from_str::<Request>(request.trim()),
|
||||
Ok(Request::Subscribe)
|
||||
));
|
||||
reader.get_mut().write_all(lines.as_bytes()).unwrap();
|
||||
});
|
||||
socket
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subscription_skips_what_it_cannot_read() {
|
||||
let socket = fake_daemon(
|
||||
"subscribe",
|
||||
"not json\n\
|
||||
{\"Changed\":{\"topics\":[\"files\",\"from_a_newer_daemon\"]}}\n\
|
||||
{\"NewEvent\":{}}\n\
|
||||
\"Heartbeat\"\n",
|
||||
);
|
||||
let mut subscription = subscribe(&socket).unwrap();
|
||||
let first = subscription.next_event().unwrap();
|
||||
let second = subscription.next_event().unwrap();
|
||||
let end = subscription.next_event().unwrap();
|
||||
let _ = std::fs::remove_file(&socket);
|
||||
match first {
|
||||
Some(Event::Changed { topics }) => {
|
||||
assert_eq!(topics, vec![Topic::Files, Topic::Unknown]);
|
||||
}
|
||||
other => panic!("expected a change, got {other:?}"),
|
||||
}
|
||||
assert!(matches!(second, Some(Event::Heartbeat)), "{second:?}");
|
||||
assert!(end.is_none(), "{end:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_daemon_without_subscriptions_is_told_apart_from_a_dead_one() {
|
||||
let socket = fake_daemon("legacy", "{\"Error\":{\"message\":\"unknown request\"}}\n");
|
||||
let mut subscription = subscribe(&socket).unwrap();
|
||||
let answer = subscription.next_event();
|
||||
let _ = std::fs::remove_file(&socket);
|
||||
assert!(matches!(answer, Err(crate::Error::Other(_))), "{answer:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_daemon_that_never_answers_times_out_as_not_responding() {
|
||||
let socket = std::env::temp_dir().join(format!("pdfs-silent-{}.sock", std::process::id()));
|
||||
let _ = std::fs::remove_file(&socket);
|
||||
// Bound but never accepted: connect succeeds, no reply ever comes.
|
||||
let _listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
|
||||
let started = std::time::Instant::now();
|
||||
let err = send(&socket, &Request::Status).unwrap_err();
|
||||
let _ = std::fs::remove_file(&socket);
|
||||
assert!(err.is_timeout(), "{err}");
|
||||
assert!(started.elapsed() < READ_TIMEOUT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photo_kind_classifies_by_extension_then_mime() {
|
||||
use PhotoKind::*;
|
||||
|
||||
@@ -9,7 +9,7 @@ use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
/// Current schema version. Bump on every forward migration added below.
|
||||
pub(super) const SCHEMA_VERSION: i64 = 34;
|
||||
pub(super) const SCHEMA_VERSION: i64 = 35;
|
||||
|
||||
impl Db {
|
||||
pub(super) fn migrate(&self) -> Result<()> {
|
||||
@@ -316,6 +316,22 @@ impl Db {
|
||||
tx.execute_batch(MIGRATION_V34)?;
|
||||
}
|
||||
}
|
||||
if current < 35 {
|
||||
// Same guards as V26-V34.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('trash') WHERE name = 'trashed_at'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_trash: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'trash'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_trash && !has_column {
|
||||
tx.execute_batch(MIGRATION_V35)?;
|
||||
}
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
@@ -1033,3 +1049,13 @@ UPDATE photos SET resolved_at = NULL;
|
||||
const MIGRATION_V34: &str = "
|
||||
ALTER TABLE photos ADD COLUMN similar_hash INTEGER;
|
||||
";
|
||||
|
||||
/// Schema v35: when each trashed node went to the trash.
|
||||
///
|
||||
/// The API does not tell this client, so `trashed_at` is when the daemon first
|
||||
/// saw the node in the trash, in epoch seconds. `NULL` when it cannot say: rows
|
||||
/// from before this version, and nodes found by a refresh that did not follow a
|
||||
/// complete one, which may have been in the trash for a long time already.
|
||||
const MIGRATION_V35: &str = "
|
||||
ALTER TABLE trash ADD COLUMN trashed_at INTEGER;
|
||||
";
|
||||
@@ -559,6 +559,15 @@ impl Db {
|
||||
if listed != Some(1) {
|
||||
return Ok(None);
|
||||
}
|
||||
drop(conn);
|
||||
self.known_children(parent).map(Some)
|
||||
}
|
||||
|
||||
/// The children the DB knows under `parent`, whether or not the folder's
|
||||
/// listing is still marked complete. Only for when nothing better can be
|
||||
/// had: a listing dropped as stale may miss or keep a child.
|
||||
pub fn known_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT node_json FROM nodes
|
||||
WHERE parent_uid = ?1 AND node_json IS NOT NULL AND trashed = 0",
|
||||
@@ -568,7 +577,7 @@ impl Db {
|
||||
for json in rows {
|
||||
out.push(serde_json::from_str(&json?)?);
|
||||
}
|
||||
Ok(Some(out))
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// --- Content-cache LRU index (P4) -------------------------------------
|
||||
|
||||
@@ -66,6 +66,52 @@ pub fn op_supersedes(kind: &str) -> bool {
|
||||
matches!(kind, OP_REVISION | OP_RENAME | OP_TRASH)
|
||||
}
|
||||
|
||||
/// Delete the ops that removing `uid` takes with it, returning the staged blobs
|
||||
/// they held.
|
||||
///
|
||||
/// That is every op for `uid` or for anything queued beneath it, plus the
|
||||
/// revisions of files the node tree places beneath it. A revision op carries
|
||||
/// no `parent_uid`, so the queue alone cannot tell that its file sits inside
|
||||
/// a removed folder: the upload outlived the folder, failed forever against a
|
||||
/// trashed node, and kept its blob on disk (`docs/BUGS.md` B102).
|
||||
///
|
||||
/// Only revisions are taken from the node tree. A queued rename that moved a
|
||||
/// node *into* the folder has to land, or the node would stay where it was
|
||||
/// on the server instead of going to the trash with the folder.
|
||||
fn drop_doomed_ops(tx: &rusqlite::Transaction<'_>, uid: &str) -> Result<Vec<String>> {
|
||||
const DOOMED: &str = "
|
||||
WITH RECURSIVE
|
||||
queued(uid) AS (
|
||||
SELECT ?1
|
||||
UNION
|
||||
SELECT p.uid FROM pending_op p JOIN queued q ON p.parent_uid = q.uid
|
||||
),
|
||||
below(uid) AS (
|
||||
SELECT ?1
|
||||
UNION
|
||||
SELECT n.uid FROM nodes n JOIN below b ON n.parent_uid = b.uid
|
||||
),
|
||||
doomed(id) AS (
|
||||
SELECT id FROM pending_op
|
||||
WHERE uid IN (SELECT uid FROM queued)
|
||||
OR (kind = 'revision' AND uid IN (SELECT uid FROM below))
|
||||
)";
|
||||
let blobs: Vec<String> = {
|
||||
let mut stmt = tx.prepare(&format!(
|
||||
"{DOOMED}
|
||||
SELECT blob_path FROM pending_op
|
||||
WHERE id IN (SELECT id FROM doomed) AND blob_path IS NOT NULL"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![uid], |r| r.get::<_, String>(0))?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
};
|
||||
tx.execute(
|
||||
&format!("{DOOMED} DELETE FROM pending_op WHERE id IN (SELECT id FROM doomed)"),
|
||||
params![uid],
|
||||
)?;
|
||||
Ok(blobs)
|
||||
}
|
||||
|
||||
/// The volume id given to a node that exists only on this machine, so far. A
|
||||
/// real [`NodeUid`] is `{volume}~{link}`, so a placeholder is `local~<uuid>` and
|
||||
/// round-trips through the same `Display`/parse path as any other uid.
|
||||
@@ -174,6 +220,24 @@ pub struct AttachedBlob {
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Whether a queued trash still holds `name` somewhere.
|
||||
///
|
||||
/// A replacing rename or a delete-then-recreate queues the trash of the old
|
||||
/// node ahead of the op that wants its name. With several drain workers the
|
||||
/// later op can run first and find the name still taken; this tells it to
|
||||
/// wait for the trash rather than land under a conflict name.
|
||||
pub fn has_pending_trash_named(&self, name: &str) -> Result<bool> {
|
||||
let conn = self.read();
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM pending_op WHERE kind = ?1 AND name = ?2
|
||||
)",
|
||||
params![OP_TRASH, name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Whether a specific desired-state operation is still queued for a node.
|
||||
pub fn has_pending_op(&self, uid: &str, kind: &str) -> Result<bool> {
|
||||
let conn = self.read();
|
||||
@@ -252,27 +316,9 @@ impl Db {
|
||||
name: &str,
|
||||
created_at: i64,
|
||||
) -> Result<(i64, Vec<String>)> {
|
||||
const SUBTREE: &str = "
|
||||
WITH RECURSIVE doomed(uid) AS (
|
||||
SELECT ?1
|
||||
UNION
|
||||
SELECT p.uid FROM pending_op p JOIN doomed d ON p.parent_uid = d.uid
|
||||
)";
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let blobs: Vec<String> = {
|
||||
let mut stmt = tx.prepare(&format!(
|
||||
"{SUBTREE}
|
||||
SELECT blob_path FROM pending_op
|
||||
WHERE uid IN (SELECT uid FROM doomed) AND blob_path IS NOT NULL"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![uid], |row| row.get::<_, String>(0))?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
};
|
||||
tx.execute(
|
||||
&format!("{SUBTREE} DELETE FROM pending_op WHERE uid IN (SELECT uid FROM doomed)"),
|
||||
params![uid],
|
||||
)?;
|
||||
let blobs = drop_doomed_ops(&tx, uid)?;
|
||||
tx.execute(
|
||||
"INSERT INTO pending_op
|
||||
(kind, uid, parent_uid, name, blob_path, meta_json, created_at, next_attempt_at)
|
||||
@@ -326,6 +372,67 @@ impl Db {
|
||||
Ok(Some(AttachedBlob { id, superseded }))
|
||||
}
|
||||
|
||||
/// Retire a queued create that has just landed as `real`, and repoint
|
||||
/// everything that named its placeholder, in one transaction.
|
||||
///
|
||||
/// The drain uploads the blob the create held when it was picked up. A
|
||||
/// write that closes while that upload is on the wire attaches a newer blob
|
||||
/// to the *same row* (`attach_blob_to_create`), so deleting the row by id
|
||||
/// threw that write away: the server kept the older bytes and nothing was
|
||||
/// left queued to replace them. When the row no longer holds `uploaded`, it
|
||||
/// becomes a revision of `real` instead, with its sidecar passed through
|
||||
/// `rewrite` so it names the real node. Returns that blob and sidecar, or
|
||||
/// `None` when the create is simply done.
|
||||
pub fn finish_create(
|
||||
&self,
|
||||
id: i64,
|
||||
uploaded: Option<&str>,
|
||||
local: &str,
|
||||
real: &str,
|
||||
rewrite: impl FnOnce(&str) -> Option<String>,
|
||||
) -> Result<Option<(String, String)>> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let row: Option<(Option<String>, Option<String>)> = tx
|
||||
.query_row(
|
||||
"SELECT blob_path, meta_json FROM pending_op WHERE id = ?1",
|
||||
params![id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.optional()?;
|
||||
let newer = match row {
|
||||
Some((Some(blob), Some(meta))) if uploaded != Some(blob.as_str()) => {
|
||||
rewrite(&meta).map(|meta| (blob, meta))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
match &newer {
|
||||
Some((_, meta)) => {
|
||||
tx.execute(
|
||||
"UPDATE pending_op
|
||||
SET kind = ?2, uid = ?3, parent_uid = NULL, name = NULL, meta_json = ?4,
|
||||
attempts = 0, last_error = NULL, next_attempt_at = 0
|
||||
WHERE id = ?1",
|
||||
params![id, OP_REVISION, real, meta],
|
||||
)?;
|
||||
}
|
||||
None => {
|
||||
tx.execute("DELETE FROM pending_op WHERE id = ?1", params![id])?;
|
||||
}
|
||||
}
|
||||
tx.execute(
|
||||
"UPDATE pending_op SET parent_uid = ?2 WHERE parent_uid = ?1",
|
||||
params![local, real],
|
||||
)?;
|
||||
tx.execute(
|
||||
"UPDATE nodes SET parent_uid = ?2 WHERE parent_uid = ?1",
|
||||
params![local, real],
|
||||
)?;
|
||||
tx.execute("DELETE FROM nodes WHERE uid = ?1", params![local])?;
|
||||
tx.commit()?;
|
||||
Ok(newer)
|
||||
}
|
||||
|
||||
/// Replace the sidecar of a queued op, for a baseline that has moved under
|
||||
/// it.
|
||||
///
|
||||
@@ -428,29 +535,12 @@ impl Db {
|
||||
/// and in the user's pending count, for the life of the database.
|
||||
///
|
||||
/// Only `create`/`mkdir` ops carry a `parent_uid`, so for a file this
|
||||
/// recursion finds nothing and costs one query.
|
||||
/// recursion finds nothing and costs one query. Revisions of files the
|
||||
/// node tree places beneath `uid` go too; see [`drop_doomed_ops`].
|
||||
pub fn delete_ops_for_uid(&self, uid: &str) -> Result<Vec<String>> {
|
||||
const SUBTREE: &str = "
|
||||
WITH RECURSIVE doomed(uid) AS (
|
||||
SELECT ?1
|
||||
UNION
|
||||
SELECT p.uid FROM pending_op p JOIN doomed d ON p.parent_uid = d.uid
|
||||
)";
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let blobs: Vec<String> = {
|
||||
let mut stmt = tx.prepare(&format!(
|
||||
"{SUBTREE}
|
||||
SELECT blob_path FROM pending_op
|
||||
WHERE uid IN (SELECT uid FROM doomed) AND blob_path IS NOT NULL"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![uid], |r| r.get::<_, String>(0))?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
};
|
||||
tx.execute(
|
||||
&format!("{SUBTREE} DELETE FROM pending_op WHERE uid IN (SELECT uid FROM doomed)"),
|
||||
params![uid],
|
||||
)?;
|
||||
let blobs = drop_doomed_ops(&tx, uid)?;
|
||||
tx.commit()?;
|
||||
Ok(blobs)
|
||||
}
|
||||
@@ -765,6 +855,19 @@ impl Db {
|
||||
/// drain replaces it with the uid the server assigns.
|
||||
pub const LOCAL_VOLUME: &str = "local";
|
||||
|
||||
/// Whether op `id` is still queued. A drain worker that claimed it can find
|
||||
/// it gone: the node was deleted, or a newer write superseded it, while the
|
||||
/// worker was talking to the server.
|
||||
pub fn op_exists(&self, id: i64) -> Result<bool> {
|
||||
let conn = self.read();
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM pending_op WHERE id = ?1",
|
||||
params![id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// Drop a queued op, once its upload has actually landed.
|
||||
pub fn delete_op(&self, id: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
|
||||
@@ -260,6 +260,88 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop the baseline rows of `rel_path` and everything under it.
|
||||
///
|
||||
/// Used when a subtree leaves a mirror folder through a move on Drive: its
|
||||
/// local copy is about to be removed, and rows left behind would read as
|
||||
/// "deleted locally" and trash the moved nodes at their new place.
|
||||
pub fn sync_entries_remove_subtree(&self, folder_id: i64, rel_path: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"DELETE FROM sync_entry
|
||||
WHERE folder_id = ?1
|
||||
AND (rel_path = ?2 OR substr(rel_path, 1, length(?2) + 1) = ?2 || '/')",
|
||||
params![folder_id, rel_path],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move the baseline rows of `from_rel` and everything under it to `to_rel`
|
||||
/// in folder `to_folder`, in one transaction.
|
||||
///
|
||||
/// Used when a move on Drive took a mirror subtree along with its local
|
||||
/// copy: the rows still describe both sides correctly, only under a new
|
||||
/// path, so the next pass on either folder has nothing to do. The two
|
||||
/// folders may be the same one.
|
||||
pub fn sync_entries_move(
|
||||
&self,
|
||||
from_folder: i64,
|
||||
from_rel: &str,
|
||||
to_folder: i64,
|
||||
to_rel: &str,
|
||||
) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut select = tx.prepare(
|
||||
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash,
|
||||
local_mtime_ns
|
||||
FROM sync_entry
|
||||
WHERE folder_id = ?1
|
||||
AND (rel_path = ?2 OR substr(rel_path, 1, length(?2) + 1) = ?2 || '/')",
|
||||
)?;
|
||||
let rows = select
|
||||
.query_map(params![from_folder, from_rel], |r| {
|
||||
Ok(StoredSyncEntry {
|
||||
rel_path: r.get(0)?,
|
||||
remote_uid: r.get(1)?,
|
||||
local_mtime: r.get(2)?,
|
||||
local_size: r.get(3)?,
|
||||
remote_rev: r.get(4)?,
|
||||
remote_hash: r.get(5)?,
|
||||
local_mtime_ns: r.get(6)?,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
let mut delete =
|
||||
tx.prepare("DELETE FROM sync_entry WHERE folder_id = ?1 AND rel_path = ?2")?;
|
||||
for row in &rows {
|
||||
delete.execute(params![from_folder, row.rel_path])?;
|
||||
}
|
||||
let mut insert = tx.prepare(
|
||||
"INSERT OR REPLACE INTO sync_entry
|
||||
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
|
||||
remote_hash, local_mtime_ns)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
)?;
|
||||
for row in rows {
|
||||
let rel = format!("{to_rel}{}", &row.rel_path[from_rel.len()..]);
|
||||
insert.execute(params![
|
||||
to_folder,
|
||||
rel,
|
||||
row.remote_uid,
|
||||
row.local_mtime,
|
||||
row.local_size,
|
||||
row.remote_rev,
|
||||
row.remote_hash,
|
||||
row.local_mtime_ns,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop the entire baseline for a folder. Used when flipping ondemand→mirror:
|
||||
/// the local tree was evicted, so the old baseline is stale and would make the
|
||||
/// next reconcile mistake "locally deleted" for "must re-download". Clearing it
|
||||
|
||||
@@ -583,6 +583,7 @@ fn trash_replace_lists_folders_first() {
|
||||
size: 10,
|
||||
mtime: 1,
|
||||
parent_uid: Some("t2".into()),
|
||||
trashed_at: Some(100),
|
||||
},
|
||||
StoredTrash {
|
||||
uid: "t2".into(),
|
||||
@@ -591,6 +592,7 @@ fn trash_replace_lists_folders_first() {
|
||||
size: 0,
|
||||
mtime: 2,
|
||||
parent_uid: None,
|
||||
trashed_at: None,
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
@@ -613,6 +615,15 @@ fn trash_replace_lists_folders_first() {
|
||||
]
|
||||
);
|
||||
|
||||
// The first-seen times round-trip, unknown ones included.
|
||||
let mut seen: Vec<_> = db.trash_seen().unwrap().into_iter().collect();
|
||||
seen.sort();
|
||||
assert_eq!(
|
||||
seen,
|
||||
[("t1".to_string(), Some(100)), ("t2".to_string(), None)]
|
||||
);
|
||||
assert_eq!(items[1].trashed_at, Some(100));
|
||||
|
||||
// A replace is a replace: emptying the trash on the server empties it here.
|
||||
db.trash_replace(&[]).unwrap();
|
||||
assert!(db.trash_list().unwrap().is_empty());
|
||||
@@ -777,6 +788,8 @@ fn children_if_listed_gated_on_flag() {
|
||||
|
||||
// Not listed yet → unknown, force a re-fetch.
|
||||
assert!(db.children_if_listed(&uid("root")).unwrap().is_none());
|
||||
// Still known, for a caller that has nothing better while offline.
|
||||
assert_eq!(db.known_children(&uid("root")).unwrap().len(), 2);
|
||||
|
||||
db.set_listed(&uid("root"), true).unwrap();
|
||||
let kids = db.children_if_listed(&uid("root")).unwrap().unwrap();
|
||||
@@ -2447,6 +2460,20 @@ fn atomic_trash_replacement_returns_owned_blobs_after_commit() {
|
||||
assert!(ops[0].blob_path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_queued_trash_holds_its_name_until_it_lands() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let node = uid("held-name").to_string();
|
||||
assert!(!db.has_pending_trash_named("report.txt").unwrap());
|
||||
|
||||
let (trash_id, _) = db.replace_ops_with_trash(&node, "report.txt", 1).unwrap();
|
||||
assert!(db.has_pending_trash_named("report.txt").unwrap());
|
||||
assert!(!db.has_pending_trash_named("other.txt").unwrap());
|
||||
|
||||
db.delete_op(trash_id).unwrap();
|
||||
assert!(!db.has_pending_trash_named("report.txt").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_atomic_trash_insert_keeps_prior_revision_and_blob_ownership() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
@@ -2487,6 +2514,71 @@ fn failed_atomic_trash_insert_keeps_prior_revision_and_blob_ownership() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A revision op names no parent, so the queue alone cannot see that its file
|
||||
/// sits inside a folder being removed. The node tree can: trashing the folder
|
||||
/// has to take the uploads of the files below it along, or they fail forever
|
||||
/// against a trashed node and keep their bytes on disk (B102). A rename that
|
||||
/// moved a node into the folder still has to land.
|
||||
#[test]
|
||||
fn trashing_a_folder_drops_the_queued_revisions_of_files_below_it() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.upsert_nodes(&[
|
||||
folder("root", None, "My Files"),
|
||||
folder("doomed", Some("root"), "doomed"),
|
||||
folder("sub", Some("doomed"), "sub"),
|
||||
file("deep", "sub", "deep.bin", 1),
|
||||
file("moved", "doomed", "moved.txt", 1),
|
||||
file("kept", "root", "kept.bin", 1),
|
||||
])
|
||||
.unwrap();
|
||||
let op = |kind: &str, link: &str, blob: Option<&str>| PendingOp {
|
||||
id: 0,
|
||||
kind: kind.to_string(),
|
||||
uid: uid(link).to_string(),
|
||||
parent_uid: None,
|
||||
name: None,
|
||||
blob_path: blob.map(str::to_string),
|
||||
meta_json: Some("{}".to_string()),
|
||||
created_at: 1,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
};
|
||||
db.enqueue_op(&op(OP_REVISION, "deep", Some("/staging/deep")))
|
||||
.unwrap();
|
||||
db.enqueue_op(&op(OP_RENAME, "moved", None)).unwrap();
|
||||
db.enqueue_op(&op(OP_REVISION, "kept", Some("/staging/kept")))
|
||||
.unwrap();
|
||||
|
||||
let doomed = uid("doomed").to_string();
|
||||
let (_, blobs) = db.replace_ops_with_trash(&doomed, "doomed", 2).unwrap();
|
||||
assert_eq!(blobs, vec!["/staging/deep"]);
|
||||
|
||||
let mut left: Vec<(String, String)> = db
|
||||
.pending_ops()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|op| (op.kind, op.uid))
|
||||
.collect();
|
||||
left.sort();
|
||||
assert_eq!(
|
||||
left,
|
||||
vec![
|
||||
(OP_RENAME.to_string(), uid("moved").to_string()),
|
||||
(OP_REVISION.to_string(), uid("kept").to_string()),
|
||||
(OP_TRASH.to_string(), doomed.clone()),
|
||||
]
|
||||
);
|
||||
|
||||
// Deleting outright follows the same tree.
|
||||
db.enqueue_op(&op(OP_REVISION, "deep", Some("/staging/deep2")))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
db.delete_ops_for_uid(&uid("sub").to_string()).unwrap(),
|
||||
vec!["/staging/deep2"]
|
||||
);
|
||||
}
|
||||
|
||||
/// Deleting a folder that was created offline must take the ops queued
|
||||
/// underneath it with it: they name a placeholder parent that will now never
|
||||
/// become real, so nothing could ever drain them and nothing is left to
|
||||
@@ -2688,6 +2780,99 @@ fn attaching_to_an_already_drained_create_reports_it_is_gone() {
|
||||
assert!(out.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_attached_while_its_create_uploads_becomes_a_revision() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let local = "local~abc";
|
||||
let real = uid("real").to_string();
|
||||
let create = |blob: Option<&str>| PendingOp {
|
||||
id: 0,
|
||||
kind: OP_CREATE.to_string(),
|
||||
uid: local.to_string(),
|
||||
parent_uid: Some(uid("parent").to_string()),
|
||||
name: Some("new.txt".to_string()),
|
||||
blob_path: blob.map(str::to_string),
|
||||
meta_json: blob.map(|_| "{\"uid\":\"local~abc\"}".to_string()),
|
||||
created_at: 1,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
};
|
||||
let (id, _) = db.enqueue_op(&create(None)).unwrap();
|
||||
// The drain picked the create up with no blob; the file's first write
|
||||
// closes while the empty upload is on the wire.
|
||||
db.attach_blob_to_create(local, "/staging/written", "{\"uid\":\"local~abc\"}")
|
||||
.unwrap()
|
||||
.expect("create is still queued");
|
||||
|
||||
let newer = db
|
||||
.finish_create(id, None, local, &real, |json| {
|
||||
Some(json.replace("local~abc", "vol~real"))
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
newer,
|
||||
Some((
|
||||
"/staging/written".to_string(),
|
||||
"{\"uid\":\"vol~real\"}".to_string()
|
||||
))
|
||||
);
|
||||
let ops = db.pending_ops().unwrap();
|
||||
assert_eq!(ops.len(), 1, "the write is still queued");
|
||||
assert_eq!(ops[0].kind, OP_REVISION);
|
||||
assert_eq!(ops[0].uid, real);
|
||||
assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/written"));
|
||||
assert_eq!(ops[0].parent_uid, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_create_whose_blob_landed_is_retired() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let local = "local~abc";
|
||||
let real = uid("real").to_string();
|
||||
let (id, _) = db
|
||||
.enqueue_op(&PendingOp {
|
||||
id: 0,
|
||||
kind: OP_CREATE.to_string(),
|
||||
uid: local.to_string(),
|
||||
parent_uid: Some(uid("parent").to_string()),
|
||||
name: Some("new.txt".to_string()),
|
||||
blob_path: Some("/staging/sent".to_string()),
|
||||
meta_json: Some("{}".to_string()),
|
||||
created_at: 1,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
})
|
||||
.unwrap();
|
||||
db.enqueue_op(&PendingOp {
|
||||
id: 0,
|
||||
kind: OP_CREATE.to_string(),
|
||||
uid: "local~child".to_string(),
|
||||
parent_uid: Some(local.to_string()),
|
||||
name: Some("inside.txt".to_string()),
|
||||
blob_path: None,
|
||||
meta_json: None,
|
||||
created_at: 2,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let newer = db
|
||||
.finish_create(id, Some("/staging/sent"), local, &real, |_| {
|
||||
panic!("an unchanged create has no sidecar to rewrite")
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(newer, None);
|
||||
let ops = db.pending_ops().unwrap();
|
||||
assert_eq!(ops.len(), 1, "only the child is left");
|
||||
assert_eq!(ops[0].parent_uid.as_deref(), Some(real.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draining_a_folder_repoints_its_queued_children() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
@@ -2909,6 +3094,94 @@ fn clearing_a_stale_pending_mode_preserves_the_newer_intent() {
|
||||
assert_eq!(db.sync_folder_get(id).unwrap().unwrap().pending_mode, None);
|
||||
}
|
||||
|
||||
fn baseline_row(rel: &str) -> StoredSyncEntry {
|
||||
StoredSyncEntry {
|
||||
rel_path: rel.into(),
|
||||
remote_uid: Some(format!("vol~{rel}")),
|
||||
local_mtime: 1_700_000_000,
|
||||
local_mtime_ns: Some(1_700_000_000_000_000_001),
|
||||
local_size: 7,
|
||||
remote_rev: Some("1700000000".into()),
|
||||
remote_hash: Some("7".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_baseline_subtree_takes_its_rows_and_leaves_name_prefixed_siblings() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let from = db.sync_folder_add("/home/me/A", "vol~a", "share").unwrap();
|
||||
let to = db.sync_folder_add("/home/me/B", "vol~b", "share").unwrap();
|
||||
for rel in [
|
||||
"docs",
|
||||
"docs/x.txt",
|
||||
"docs/sub/y.txt",
|
||||
"docsextra.txt",
|
||||
"other",
|
||||
] {
|
||||
db.sync_entry_upsert(from, &baseline_row(rel)).unwrap();
|
||||
}
|
||||
db.sync_entry_upsert(to, &baseline_row("inbox")).unwrap();
|
||||
|
||||
db.sync_entries_move(from, "docs", to, "inbox/docs")
|
||||
.unwrap();
|
||||
|
||||
let left = db.sync_entries(from).unwrap();
|
||||
let mut left: Vec<_> = left.keys().cloned().collect();
|
||||
left.sort();
|
||||
assert_eq!(left, ["docsextra.txt", "other"]);
|
||||
|
||||
let moved = db.sync_entries(to).unwrap();
|
||||
let mut keys: Vec<_> = moved.keys().cloned().collect();
|
||||
keys.sort();
|
||||
assert_eq!(
|
||||
keys,
|
||||
[
|
||||
"inbox",
|
||||
"inbox/docs",
|
||||
"inbox/docs/sub/y.txt",
|
||||
"inbox/docs/x.txt"
|
||||
]
|
||||
);
|
||||
// Only the path changes: the row still describes the same node on both sides.
|
||||
assert_eq!(
|
||||
moved["inbox/docs/x.txt"],
|
||||
StoredSyncEntry {
|
||||
rel_path: "inbox/docs/x.txt".into(),
|
||||
..baseline_row("docs/x.txt")
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_baseline_subtree_within_one_folder_renames_it() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let folder = db.sync_folder_add("/home/me/A", "vol~a", "share").unwrap();
|
||||
for rel in ["old", "old/f.txt", "new"] {
|
||||
db.sync_entry_upsert(folder, &baseline_row(rel)).unwrap();
|
||||
}
|
||||
|
||||
db.sync_entries_move(folder, "old", folder, "new/old")
|
||||
.unwrap();
|
||||
|
||||
let mut keys: Vec<_> = db.sync_entries(folder).unwrap().into_keys().collect();
|
||||
keys.sort();
|
||||
assert_eq!(keys, ["new", "new/old", "new/old/f.txt"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_baseline_subtree_keeps_name_prefixed_siblings() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let folder = db.sync_folder_add("/home/me/A", "vol~a", "share").unwrap();
|
||||
for rel in ["docs", "docs/x.txt", "docsextra.txt"] {
|
||||
db.sync_entry_upsert(folder, &baseline_row(rel)).unwrap();
|
||||
}
|
||||
|
||||
db.sync_entries_remove_subtree(folder, "docs").unwrap();
|
||||
|
||||
let keys: Vec<_> = db.sync_entries(folder).unwrap().into_keys().collect();
|
||||
assert_eq!(keys, ["docsextra.txt"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_uid_lookup_covers_the_root_and_synced_descendants_only() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
@@ -4952,3 +5225,28 @@ fn retry_all_touches_only_ops_that_have_failed() {
|
||||
assert_eq!(due(debounced), 7_000);
|
||||
assert_eq!(due(parked), ops::PARK_UNTIL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_queued_op_stops_existing_once_the_node_is_deleted() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let (id, _) = db
|
||||
.enqueue_op(&PendingOp {
|
||||
id: 0,
|
||||
kind: OP_REVISION.to_string(),
|
||||
uid: uid("journal").to_string(),
|
||||
parent_uid: None,
|
||||
name: None,
|
||||
blob_path: Some("/staging/journal".to_string()),
|
||||
meta_json: Some("{}".to_string()),
|
||||
created_at: 1,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(db.op_exists(id).unwrap());
|
||||
|
||||
let blobs = db.delete_ops_for_uid(&uid("journal").to_string()).unwrap();
|
||||
assert_eq!(blobs, vec!["/staging/journal"]);
|
||||
assert!(!db.op_exists(id).unwrap());
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Trashed nodes, persisted so the trash view opens without a round-trip.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use super::Db;
|
||||
@@ -15,6 +17,9 @@ pub struct StoredTrash {
|
||||
/// row written before schema v29, and for a node whose parent the server
|
||||
/// did not report.
|
||||
pub parent_uid: Option<String>,
|
||||
/// When the daemon first saw the node in the trash, epoch seconds. `None`
|
||||
/// when it cannot say (see schema v35).
|
||||
pub trashed_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
@@ -24,8 +29,8 @@ impl Db {
|
||||
tx.execute("DELETE FROM trash", [])?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO trash (uid, name, is_dir, size, mtime, parent_uid) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
"INSERT INTO trash (uid, name, is_dir, size, mtime, parent_uid, trashed_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
)?;
|
||||
for item in items {
|
||||
stmt.execute(params![
|
||||
@@ -34,7 +39,8 @@ impl Db {
|
||||
item.is_dir as i64,
|
||||
item.size,
|
||||
item.mtime,
|
||||
item.parent_uid
|
||||
item.parent_uid,
|
||||
item.trashed_at
|
||||
])?;
|
||||
}
|
||||
}
|
||||
@@ -47,7 +53,7 @@ impl Db {
|
||||
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT uid, name, is_dir, size, mtime, parent_uid FROM trash
|
||||
"SELECT uid, name, is_dir, size, mtime, parent_uid, trashed_at FROM trash
|
||||
ORDER BY is_dir DESC, name COLLATE NOCASE",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
@@ -58,6 +64,7 @@ impl Db {
|
||||
size: r.get(3)?,
|
||||
mtime: r.get(4)?,
|
||||
parent_uid: r.get(5)?,
|
||||
trashed_at: r.get(6)?,
|
||||
})
|
||||
})?;
|
||||
let mut items = Vec::new();
|
||||
@@ -81,5 +88,19 @@ impl Db {
|
||||
Ok(pairs)
|
||||
}
|
||||
|
||||
/// When each node now in the trash was first seen there, keyed by uid, so
|
||||
/// a refresh can carry the times over into the listing it writes.
|
||||
pub fn trash_seen(&self) -> Result<HashMap<String, Option<i64>>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare("SELECT uid, trashed_at FROM trash")?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||
let mut seen = HashMap::new();
|
||||
for row in rows {
|
||||
let (uid, at) = row?;
|
||||
seen.insert(uid, at);
|
||||
}
|
||||
Ok(seen)
|
||||
}
|
||||
|
||||
// ---- device sync (devices.md) -----------------------------------------
|
||||
}
|
||||
@@ -66,6 +66,20 @@ impl Error {
|
||||
}
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Whether a control-socket round-trip gave up waiting for the reply: the
|
||||
/// daemon is running but stuck, as opposed to not running at all.
|
||||
pub fn is_timeout(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Error::Io(e) if matches!(
|
||||
e.kind(),
|
||||
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// A failure on its way to a front-end: prose for the user, plus the
|
||||
|
||||
@@ -55,9 +55,10 @@ fn systemctl_query(args: &[&str]) -> bool {
|
||||
/// 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.
|
||||
/// unit when it is not running at all. `reenable` for the reason given on
|
||||
/// [`enable`].
|
||||
pub fn enable_start() -> bool {
|
||||
let enabled = systemctl(&["enable"]);
|
||||
let enabled = systemctl(&["reenable"]);
|
||||
systemctl(&["restart"]) && enabled
|
||||
}
|
||||
|
||||
@@ -97,8 +98,14 @@ pub fn is_enabled() -> bool {
|
||||
/// Enable the service so it auto-starts on future logins, without necessarily
|
||||
/// starting it now beyond what `enable` does. Used by the Settings "Start on
|
||||
/// login" switch; pairs with [`disable`](disable).
|
||||
///
|
||||
/// `reenable` rather than `enable`: older installs were enabled
|
||||
/// into `default.target.wants`, and a plain `enable` would add the new
|
||||
/// `graphical-session.target.wants` link while leaving the old one to start the
|
||||
/// unit too early. `reenable` drops every existing link and recreates them from
|
||||
/// the current `[Install]` section.
|
||||
pub fn enable() -> bool {
|
||||
systemctl(&["enable"])
|
||||
systemctl(&["reenable"])
|
||||
}
|
||||
|
||||
/// Disable auto-start without stopping the running mount. Used by the Settings
|
||||
|
||||
@@ -96,6 +96,26 @@ pub fn is_transient_name(name: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether `name` is scratch another program writes and throws away: the
|
||||
/// [`is_transient_name`] set, plus the save-by-rename temporaries of GTK, Qt,
|
||||
/// Chromium and Electron, generic `.tmp` files and Emacs lock files. None of
|
||||
/// them is a file the user made, so a front-end can hide them from a history
|
||||
/// of what happened to the user's files.
|
||||
pub fn is_scratch_name(name: &str) -> bool {
|
||||
if is_transient_name(name) {
|
||||
return true;
|
||||
}
|
||||
let lower = name.to_ascii_lowercase();
|
||||
lower.ends_with(".tmp")
|
||||
|| lower.ends_with(".temp")
|
||||
|| name.starts_with(".goutputstream-")
|
||||
|| name.starts_with(".org.chromium.")
|
||||
|| name.starts_with(".com.google.chrome.")
|
||||
|| name.starts_with(".#")
|
||||
|| (name.starts_with('#') && name.ends_with('#') && name.len() > 1)
|
||||
|| (name.starts_with('.') && name.contains(".tmp."))
|
||||
}
|
||||
|
||||
/// Filenames accepted as a folder's ignore file, in precedence order.
|
||||
const IGNORE_FILE_NAMES: &[&str] = &[".pdfsignore", ".protonignore"];
|
||||
|
||||
@@ -341,6 +361,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_by_rename_temporaries_are_scratch() {
|
||||
for name in [
|
||||
".goutputstream-4XK2B2",
|
||||
".org.chromium.Chromium.aBc123",
|
||||
"~$report.docx",
|
||||
".~lock.budget.ods#",
|
||||
".#notes.org",
|
||||
"#notes.org#",
|
||||
"cache.tmp",
|
||||
"page.html.crdownload",
|
||||
] {
|
||||
assert!(is_scratch_name(name), "{name}");
|
||||
}
|
||||
for name in ["report.docx", "notes.org", "#1 hits.txt", ".bashrc"] {
|
||||
assert!(!is_scratch_name(name), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finished_names_are_not_transient() {
|
||||
for name in [
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use pdfs_core::control::{AlbumInfo, PhotoItem};
|
||||
use pdfs_core::control::{AlbumInfo, PhotoItem, Topic};
|
||||
use pdfs_core::db::StoredAlbum;
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
|
||||
@@ -137,10 +137,12 @@ impl Core {
|
||||
}
|
||||
let core = self.clone();
|
||||
self.rt.spawn(async move {
|
||||
if let Err(e) = core.refresh_albums().await {
|
||||
warn!(error = %e, "background album refresh failed");
|
||||
}
|
||||
let refreshed = core.refresh_albums().await;
|
||||
core.albums_refreshing.store(false, Ordering::SeqCst);
|
||||
match refreshed {
|
||||
Ok(_) => core.events.publish(&[Topic::Photos]),
|
||||
Err(e) => warn!(error = %e, "background album refresh failed"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::shutdown::Shutdown;
|
||||
///
|
||||
/// Returns whether this mount actually held the node, so the caller can evict
|
||||
/// its content blob once if *any* mount did.
|
||||
fn forget_and_notify(st: &mut State, notify: &mut NotifyBatch, uid: &NodeUid) -> bool {
|
||||
pub(crate) fn forget_and_notify(st: &mut State, notify: &mut NotifyBatch, uid: &NodeUid) -> bool {
|
||||
// Capture the inode before `forget` clears the uid mapping.
|
||||
let child = st.by_uid.get(uid).copied();
|
||||
let Some((parent, name)) = st.forget(uid) else {
|
||||
@@ -378,9 +378,10 @@ pub(super) async fn run_event_sync(
|
||||
info!(?cursor, "event sync started");
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
let events = match client.enumerate_events(&scope, cursor.as_ref()).await {
|
||||
core.next_poll(POLL_INTERVAL).await;
|
||||
let events = match link::bounded(client.enumerate_events(&scope, cursor.as_ref())).await {
|
||||
Ok(events) => events,
|
||||
Err(e) if core.lost_link(&e, "poll events") => continue,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "event poll failed; retrying after interval");
|
||||
continue;
|
||||
@@ -422,6 +423,10 @@ pub(super) async fn run_event_sync(
|
||||
cursor = Some(applied);
|
||||
}
|
||||
flush_dirty_parents(&core, &dirty);
|
||||
// Another device changed the tree: whatever a front-end lists may have
|
||||
// moved, including what is shared and what is in the trash.
|
||||
core.events
|
||||
.publish(&[Topic::Files, Topic::Trash, Topic::Shares, Topic::Conflicts]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,9 +472,10 @@ pub(super) async fn run_photos_event_sync(core: Core) {
|
||||
info!(?cursor, "photo event sync started");
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
let events = match photos.enumerate_events(&scope, cursor.as_ref()).await {
|
||||
core.next_poll(POLL_INTERVAL).await;
|
||||
let events = match link::bounded(photos.enumerate_events(&scope, cursor.as_ref())).await {
|
||||
Ok(events) => events,
|
||||
Err(error) if core.lost_link(&error, "poll photo events") => continue,
|
||||
Err(error) => {
|
||||
warn!(%error, "photo event poll failed; retrying after interval");
|
||||
continue;
|
||||
@@ -479,6 +485,7 @@ pub(super) async fn run_photos_event_sync(core: Core) {
|
||||
continue;
|
||||
};
|
||||
apply_photo_events(&core, &events);
|
||||
core.events.publish(&[Topic::Photos]);
|
||||
// One cursor write per batch, after the batch is applied: a crash in
|
||||
// between replays events whose effects are idempotent.
|
||||
if let Err(error) = core.db.set_state_str(PHOTOS_EVENT_CURSOR, head.as_str()) {
|
||||
|
||||
@@ -17,8 +17,8 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
ActivityKind, PhotoMonth, RefreshScope, Request as CtlRequest, Response as CtlResponse,
|
||||
TransferDirection, TrashFailure,
|
||||
ActivityKind, Event, HEARTBEAT, PhotoMonth, RefreshScope, Request as CtlRequest,
|
||||
Response as CtlResponse, Topic, TransferDirection, TrashFailure,
|
||||
};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
@@ -286,6 +286,155 @@ impl Drop for RefreshGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// The reply to [`CtlRequest::Status`], which subscriptions are also sent
|
||||
/// whenever it changes.
|
||||
pub(crate) fn status_response(core: &Core, username: &str, mountpoint: &Path) -> CtlResponse {
|
||||
let pins = core.cache.list_pins();
|
||||
let queued = core.db.pending_op_counts().unwrap_or_default();
|
||||
let staging = core.cache.staging_usage();
|
||||
CtlResponse::Status {
|
||||
parked_uploads: queued.parked.max(0) as u64,
|
||||
failing_ops: queued.failing.max(0) as u64,
|
||||
failing_error: queued.last_error.clone(),
|
||||
staged_bytes: staging.bytes,
|
||||
staged_oldest_secs: staging.oldest_secs,
|
||||
paused: core.sync_pause().is_some(),
|
||||
paused_until: core.sync_pause().flatten(),
|
||||
username: username.to_string(),
|
||||
mountpoint: mountpoint.display().to_string(),
|
||||
pinned: pins.len(),
|
||||
used: core.cache.usage(),
|
||||
budget: core.cache.budget(),
|
||||
pins,
|
||||
online: core.online.load(Ordering::Relaxed),
|
||||
pending_uploads: queued.uploads.max(0) as u64,
|
||||
pending_changes: queued.changes.max(0) as u64,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a front-end shows that `request` changes when it succeeds. Requests
|
||||
/// from the CLI publish the same way as the app's, so an open window follows
|
||||
/// a change made in a terminal.
|
||||
fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
|
||||
use CtlRequest as R;
|
||||
match request {
|
||||
R::Pin { .. } | R::Unpin { .. } => &[Topic::Files],
|
||||
R::CreateAlbum { .. }
|
||||
| R::RenameAlbum { .. }
|
||||
| R::DeleteAlbum { .. }
|
||||
| R::AddToAlbum { .. }
|
||||
| R::RemoveFromAlbum { .. }
|
||||
| R::SetPhotoFavorite { .. }
|
||||
| R::UploadPhoto { .. } => &[Topic::Photos],
|
||||
R::TrashNodes { .. } => &[Topic::Files, Topic::Photos, Topic::Trash, Topic::Conflicts],
|
||||
R::Rename { .. } | R::Move { .. } | R::CreateFolder { .. } | R::UploadPaths { .. } => {
|
||||
&[Topic::Files, Topic::Conflicts]
|
||||
}
|
||||
R::Delete { .. } => &[Topic::Files, Topic::Trash, Topic::Conflicts],
|
||||
R::Restore { .. } | R::DeleteForever { .. } | R::EmptyTrash => {
|
||||
&[Topic::Files, Topic::Photos, Topic::Trash, Topic::Conflicts]
|
||||
}
|
||||
R::Refresh { scope } => match scope {
|
||||
RefreshScope::Dir { .. } => &[Topic::Files],
|
||||
RefreshScope::Trash => &[Topic::Trash],
|
||||
// A photos refresh answers once it has started; the timeline
|
||||
// publishes again when it lands.
|
||||
RefreshScope::Photos { .. } => &[],
|
||||
},
|
||||
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
|
||||
R::AdoptDevice { .. } => &[Topic::Devices, Topic::Locations],
|
||||
R::AddSyncFolder { .. }
|
||||
| R::RemoveSyncFolder { .. }
|
||||
| R::SetSyncFolderMode { .. }
|
||||
| R::SetSyncFolderPaused { .. }
|
||||
| R::RestoreSyncFolders { .. }
|
||||
| R::RestoreDeviceFolders { .. } => &[Topic::Locations, Topic::Devices],
|
||||
R::RetryPendingOp { .. } => &[Topic::Queue],
|
||||
R::ResolveConflict { .. } => &[Topic::Files, Topic::Trash, Topic::Conflicts],
|
||||
R::ShareNode { .. }
|
||||
| R::ShareNodeByUid { .. }
|
||||
| R::UpdateShareRole { .. }
|
||||
| R::UpdateShareRoleByUid { .. }
|
||||
| R::RemoveShareEntry { .. }
|
||||
| R::RemoveShareEntryByUid { .. }
|
||||
| R::CreatePublicLink { .. }
|
||||
| R::CreatePublicLinkByUid { .. }
|
||||
| R::RemovePublicLink { .. }
|
||||
| R::RemovePublicLinkByUid { .. }
|
||||
| R::StopSharing { .. }
|
||||
| R::StopSharingByUid { .. } => &[Topic::Shares, Topic::Files],
|
||||
R::LeaveShared { .. }
|
||||
| R::AcceptInvitation { .. }
|
||||
| R::RejectInvitation { .. }
|
||||
| R::CreateBookmark { .. }
|
||||
| R::DeleteBookmark { .. } => &[Topic::Shares],
|
||||
R::RestoreRevision { .. }
|
||||
| R::RestoreRevisionByUid { .. }
|
||||
| R::SaveRevisionAs { .. }
|
||||
| R::SaveRevisionAsByUid { .. } => &[Topic::Files],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a subscription's writes may block. A client that stops reading
|
||||
/// is dropped after this rather than holding its handler forever.
|
||||
const SUBSCRIPTION_WRITE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Serve a [`CtlRequest::Subscribe`]: send the current status and transfers
|
||||
/// and say every topic changed, so the client catches up, then send whatever
|
||||
/// the [`EventHub`](super::events::EventHub) collects, with a heartbeat when
|
||||
/// nothing does. Ends when the client goes away or the daemon stops.
|
||||
fn serve_subscription(core: &Core, username: &str, mountpoint: &Path, mut stream: UnixStream) {
|
||||
let Some(mailbox) = core.events.subscribe() else {
|
||||
warn!("control: refusing a subscription at the limit");
|
||||
if let Ok(mut out) = serde_json::to_vec(&CtlResponse::error(CoreError::remote(
|
||||
"too many subscriptions",
|
||||
))) {
|
||||
out.push(b'\n');
|
||||
let _ = stream.write_all(&out);
|
||||
}
|
||||
return;
|
||||
};
|
||||
struct Unsubscribe<'a>(&'a Core, std::sync::Arc<super::events::Mailbox>);
|
||||
impl Drop for Unsubscribe<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.events.unsubscribe(&self.1);
|
||||
}
|
||||
}
|
||||
let _unsubscribe = Unsubscribe(core, mailbox.clone());
|
||||
if let Err(e) = stream.set_write_timeout(Some(SUBSCRIPTION_WRITE_TIMEOUT)) {
|
||||
warn!(error = %e, "control: could not set subscription timeout");
|
||||
return;
|
||||
}
|
||||
let heartbeat = super::events::event_line(&Event::Heartbeat);
|
||||
let mut lines: Vec<_> = [
|
||||
super::events::event_line(&Event::Status(Box::new(status_response(
|
||||
core, username, mountpoint,
|
||||
)))),
|
||||
super::events::transfers_line(core.transfers.snapshot(), core.jobs_snapshot()),
|
||||
super::events::event_line(&Event::Changed {
|
||||
topics: Topic::ALL.to_vec(),
|
||||
}),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
loop {
|
||||
for line in &lines {
|
||||
if stream.write_all(line.as_bytes()).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if core.shutdown.is_stopping() {
|
||||
return;
|
||||
}
|
||||
lines = mailbox.take(HEARTBEAT);
|
||||
if lines.is_empty() {
|
||||
lines.extend(heartbeat.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: UnixStream) {
|
||||
if let Err(e) = stream.set_read_timeout(Some(CONTROL_READ_TIMEOUT)) {
|
||||
warn!(error = %e, "control: could not set request timeout");
|
||||
@@ -310,32 +459,21 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
return;
|
||||
}
|
||||
};
|
||||
let request = serde_json::from_str::<CtlRequest>(line.trim());
|
||||
// A subscription lasts as long as its client, so it is not a request in
|
||||
// flight and stays out of the diagnostics' list of them.
|
||||
if let Ok(CtlRequest::Subscribe) = request {
|
||||
serve_subscription(core, username, mountpoint, stream);
|
||||
return;
|
||||
}
|
||||
let touched = request.as_ref().map_or(&[][..], touched_topics);
|
||||
// Registered before the request is even parsed, so a handler that wedges
|
||||
// inside the daemon shows up by name in `pdfs diagnostics`.
|
||||
let _inflight = super::diagnostics::InflightGuard::new(super::diagnostics::request_kind(&line));
|
||||
let response = match serde_json::from_str::<CtlRequest>(line.trim()) {
|
||||
Ok(CtlRequest::Status) => {
|
||||
let pins = core.cache.list_pins();
|
||||
let queued = core.db.pending_op_counts().unwrap_or_default();
|
||||
let staging = core.cache.staging_usage();
|
||||
CtlResponse::Status {
|
||||
parked_uploads: queued.parked.max(0) as u64,
|
||||
failing_ops: queued.failing.max(0) as u64,
|
||||
failing_error: queued.last_error.clone(),
|
||||
staged_bytes: staging.bytes,
|
||||
staged_oldest_secs: staging.oldest_secs,
|
||||
paused: core.sync_pause().is_some(),
|
||||
paused_until: core.sync_pause().flatten(),
|
||||
username: username.to_string(),
|
||||
mountpoint: mountpoint.display().to_string(),
|
||||
pinned: pins.len(),
|
||||
used: core.cache.usage(),
|
||||
budget: core.cache.budget(),
|
||||
pins,
|
||||
online: core.online.load(Ordering::Relaxed),
|
||||
pending_uploads: queued.uploads.max(0) as u64,
|
||||
pending_changes: queued.changes.max(0) as u64,
|
||||
}
|
||||
let response = match request {
|
||||
Ok(CtlRequest::Status) => status_response(core, username, mountpoint),
|
||||
Ok(CtlRequest::Subscribe) => {
|
||||
CtlResponse::error(CoreError::invalid("a subscription is served on its own"))
|
||||
}
|
||||
Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.pin(&rel) {
|
||||
@@ -836,6 +974,24 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
// Two absolute paths may name two different locations, which only
|
||||
// `move_between` can resolve; a relative pair keeps the old meaning.
|
||||
Ok(CtlRequest::Move { path, new_parent })
|
||||
if Path::new(&path).is_absolute() && Path::new(&new_parent).is_absolute() =>
|
||||
{
|
||||
match core.move_between(Path::new(&path), Path::new(&new_parent)) {
|
||||
Ok(name) => {
|
||||
core.log_activity(ActivityKind::Move, &name, format!("to {new_parent}"), true);
|
||||
CtlResponse::Ok {
|
||||
message: format!("moved {name}"),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
core.log_activity(ActivityKind::Move, &path, &e, false);
|
||||
CtlResponse::error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::Move { path, new_parent }) => {
|
||||
match (
|
||||
rel_to_mount(mountpoint, &path),
|
||||
@@ -1584,6 +1740,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
// The request did not parse: the caller sent something malformed.
|
||||
Err(e) => CtlResponse::error(CoreError::invalid(format!("bad request: {e}"))),
|
||||
};
|
||||
if !touched.is_empty() && !matches!(response, CtlResponse::Error { .. }) {
|
||||
core.events.publish(touched);
|
||||
}
|
||||
let mut out = match serde_json::to_vec(&response) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
|
||||
@@ -38,10 +38,10 @@ use pdfs_core::db::{
|
||||
};
|
||||
use proton_drive_rs::proton_sdk::error::ProtonError;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use proton_drive_rs::{Node, NodeMoveItem};
|
||||
use proton_drive_rs::{Node, NodeKind, NodeMoveItem};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use super::state::Intervals;
|
||||
use super::state::{Intervals, PendingRevision};
|
||||
use super::transfers::CountingReader;
|
||||
use super::{
|
||||
Core, DRAIN_BACKOFF_MAX, DRAIN_BACKOFF_MIN, DRAIN_IDLE_POLL, DRAIN_REVISION_DEBOUNCE,
|
||||
@@ -673,6 +673,9 @@ impl Core {
|
||||
// *different* name is the non-destructive resolution: it neither
|
||||
// clobbers their file nor drops ours, and it is visible.
|
||||
Err(e) if is_already_exists(&e) => {
|
||||
if self.db.has_pending_trash_named(&name)? {
|
||||
return Err(held_by_queued_trash(&name));
|
||||
}
|
||||
let alt = conflict_name(&name, now_secs());
|
||||
warn!(%uid, name, alt, "destination already holds that name; using a conflict name");
|
||||
self.move_renaming(&uid, &parent, Some(&alt))?;
|
||||
@@ -696,8 +699,8 @@ impl Core {
|
||||
let landed = match self.drain_rename_in_place(&uid, &node.name, &name) {
|
||||
Ok(landed) => landed,
|
||||
// The node went with it; nothing is left to rename.
|
||||
Err(e) if is_gone(&e) => node.name.clone(),
|
||||
Err(e) => return Err(e.into()),
|
||||
Err(e) if is_gone(e.as_ref()) => node.name.clone(),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
self.log_activity(
|
||||
ActivityKind::Rename,
|
||||
@@ -725,16 +728,19 @@ impl Core {
|
||||
uid: &NodeUid,
|
||||
current: &str,
|
||||
name: &str,
|
||||
) -> Result<String, ProtonError> {
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
if current == name {
|
||||
return Ok(current.to_string());
|
||||
}
|
||||
match self.rt.block_on(self.client.rename_node(uid, name, None)) {
|
||||
match self.rename_remote(uid, name) {
|
||||
Ok(()) => Ok(name.to_string()),
|
||||
Err(e) if is_already_exists(&e) => {
|
||||
if self.db.has_pending_trash_named(name)? {
|
||||
return Err(held_by_queued_trash(name));
|
||||
}
|
||||
let alt = conflict_name(name, now_secs());
|
||||
warn!(%uid, name, alt, "rename target name is taken; using a conflict name");
|
||||
self.rt.block_on(self.client.rename_node(uid, &alt, None))?;
|
||||
self.rename_remote(uid, &alt)?;
|
||||
self.adopt_drained_name(uid, &alt);
|
||||
self.log_activity(
|
||||
ActivityKind::Rename,
|
||||
@@ -744,7 +750,7 @@ impl Core {
|
||||
);
|
||||
Ok(alt)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -842,8 +848,26 @@ impl Core {
|
||||
// reachable only because the op waited, which is the whole point of the
|
||||
// queue. Never overwrite theirs and never drop ours: land under a
|
||||
// conflict name, exactly as the sync engine does.
|
||||
//
|
||||
// Two things can hold the name that are not someone else's file. The
|
||||
// node a create made before the network dropped its answer is ours: the
|
||||
// create was queued because nobody heard back, so adopt it. And a node
|
||||
// whose trash is still queued is about to let go of it.
|
||||
let mut name = wanted.clone();
|
||||
let mut adopted = false;
|
||||
let mut real = self.create_drained_node(op, &parent, &name);
|
||||
if real.as_ref().is_err_and(|e| is_already_exists(e.as_ref()))
|
||||
&& let Some(twin) = self.adoptable_twin(op, &parent, &wanted)?
|
||||
{
|
||||
info!(%local, %twin, wanted, "the name is held by our own unanswered create; adopting it");
|
||||
real = Ok(twin);
|
||||
adopted = true;
|
||||
}
|
||||
if real.as_ref().is_err_and(|e| is_already_exists(e.as_ref()))
|
||||
&& self.db.has_pending_trash_named(&wanted)?
|
||||
{
|
||||
return Err(held_by_queued_trash(&wanted));
|
||||
}
|
||||
if real.as_ref().is_err_and(|e| is_already_exists(e.as_ref())) {
|
||||
name = conflict_name(&wanted, now_secs());
|
||||
warn!(%local, wanted, name, "name is taken remotely; creating under a conflict name");
|
||||
@@ -886,20 +910,62 @@ impl Core {
|
||||
// Retire the op before touching anything else: if we crash here the node
|
||||
// exists remotely and the local placeholder is reconciled by the event
|
||||
// sync, whereas a surviving op would create the file a second time.
|
||||
self.db.delete_op(op.id)?;
|
||||
// An adopted node was made empty, so a blob this op carries has not been
|
||||
// uploaded: it goes on as a revision of the node instead.
|
||||
let uploaded = (!adopted).then_some(op.blob_path.as_deref()).flatten();
|
||||
let newer = self.retire_create(op, uploaded, &local, &real)?;
|
||||
self.adopt_real_uid(&local, &real)?;
|
||||
// The feed will report this create back to us; the tree already has it
|
||||
// under its real uid, so that event is ours to ignore (`Core::self_changes`).
|
||||
self.note_self_change(&real);
|
||||
if let Some(blob) = op.blob_path.as_deref() {
|
||||
// The uploaded blob is done with either way: a write that replaced it
|
||||
// while it was on the wire already discarded it when it attached.
|
||||
if let Some(blob) = uploaded {
|
||||
self.cache.discard_staged(Path::new(blob));
|
||||
}
|
||||
self.pending.lock().remove(&local);
|
||||
if newer {
|
||||
info!(%local, %real, name, "a write landed during the create; queued it as a revision");
|
||||
self.wake_drain();
|
||||
}
|
||||
self.log_activity(ActivityKind::Upload, &name, "created", true);
|
||||
info!(%local, %real, name, kind = %op.kind, "pending create landed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The node already holding `name` under `parent`, if it is the one an
|
||||
/// earlier attempt at this op made before its answer was lost.
|
||||
///
|
||||
/// A create that timed out may still have landed; the op was queued because
|
||||
/// the caller never heard back. Without this, its replay forked a
|
||||
/// `(sync-conflict)` copy of a file nobody else had touched.
|
||||
fn adoptable_twin(
|
||||
&self,
|
||||
op: &PendingOp,
|
||||
parent: &NodeUid,
|
||||
name: &str,
|
||||
) -> Result<Option<NodeUid>, Box<dyn std::error::Error>> {
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_folder_children_node_uids(parent))?;
|
||||
let children = self.rt.block_on(self.client.enumerate_nodes_light(&uids))?;
|
||||
let Some(twin) = children
|
||||
.into_iter()
|
||||
.find(|node| node.name == name && !node.trashed)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
// The light listing carries no file size, and an adoptable file is
|
||||
// exactly an empty one.
|
||||
let twin = match twin.kind {
|
||||
NodeKind::Folder => twin,
|
||||
NodeKind::File { .. } => match self.fetch_node_remote(&twin.uid)? {
|
||||
Some(node) => node,
|
||||
None => return Ok(None),
|
||||
},
|
||||
};
|
||||
Ok(adoptable(op.kind == OP_MKDIR, &twin, op.created_at).then_some(twin.uid))
|
||||
}
|
||||
|
||||
/// Make one queued `create`/`mkdir` real under a given name, and hand back
|
||||
/// the API's own error so the caller can tell a name clash from a failure.
|
||||
///
|
||||
@@ -963,6 +1029,62 @@ impl Core {
|
||||
Ok(uid)
|
||||
}
|
||||
|
||||
/// Retire a landed create's op and move its queued state to the real uid.
|
||||
/// True when a newer write attached to the create while it was uploading,
|
||||
/// which is now queued as a revision of `real`.
|
||||
///
|
||||
/// Runs under the `pending` lock, which `Core::enqueue_staged_write` also
|
||||
/// holds from attaching a blob to a create until it records the pending
|
||||
/// entry. So a write either attached before this ran and is carried over,
|
||||
/// or finds the create gone and queues against the real uid itself. The
|
||||
/// open write handles are repointed right after, for the same reason:
|
||||
/// a handle released later must not queue against the placeholder.
|
||||
fn retire_create(
|
||||
&self,
|
||||
op: &PendingOp,
|
||||
uploaded: Option<&str>,
|
||||
local: &NodeUid,
|
||||
real: &NodeUid,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let real_str = real.to_string();
|
||||
let newer = {
|
||||
let mut pending = self.pending.lock();
|
||||
let newer =
|
||||
self.db
|
||||
.finish_create(op.id, uploaded, &local.to_string(), &real_str, |json| {
|
||||
let mut meta: StagedWrite = serde_json::from_str(json).ok()?;
|
||||
meta.uid = real_str.clone();
|
||||
serde_json::to_string(&meta).ok()
|
||||
})?;
|
||||
pending.remove(local);
|
||||
if let Some((blob, json)) = &newer {
|
||||
let meta: StagedWrite = serde_json::from_str(json)?;
|
||||
pending.insert(
|
||||
real.clone(),
|
||||
PendingRevision {
|
||||
path: PathBuf::from(blob),
|
||||
meta,
|
||||
},
|
||||
);
|
||||
}
|
||||
newer.is_some()
|
||||
};
|
||||
self.for_each_state(|st| {
|
||||
if let Some(ino) = st.by_uid.remove(local) {
|
||||
st.by_uid.insert(real.clone(), ino);
|
||||
if let Some(e) = st.entries.get_mut(&ino) {
|
||||
e.uid = real.clone();
|
||||
}
|
||||
}
|
||||
for aw in st.active_writes.values_mut() {
|
||||
if aw.uid == *local {
|
||||
aw.uid = real.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(newer)
|
||||
}
|
||||
|
||||
/// Swap a placeholder uid for the real one across everything that keyed off
|
||||
/// it: queued children, the DB, the in-memory tree, and the caches.
|
||||
///
|
||||
@@ -985,17 +1107,22 @@ impl Core {
|
||||
.fetch_node(real)
|
||||
.map_err(|e| self.errno_error(e, "fetch node"))?;
|
||||
// Repoints queued children and node rows, and drops the placeholder row.
|
||||
// A no-op after `retire_create`, which has already done both.
|
||||
self.db
|
||||
.remap_local_uid(&local.to_string(), &real.to_string())?;
|
||||
|
||||
self.for_each_state(|st| {
|
||||
let Some(ino) = st.by_uid.remove(local) else {
|
||||
if let Some(ino) = st.by_uid.remove(local) {
|
||||
st.by_uid.insert(real.clone(), ino);
|
||||
}
|
||||
let Some(&ino) = st.by_uid.get(real) else {
|
||||
return;
|
||||
};
|
||||
st.by_uid.insert(real.clone(), ino);
|
||||
let mut landed = node.clone();
|
||||
st.keep_open_write_size(ino, &mut landed);
|
||||
if let Some(e) = st.entries.get_mut(&ino) {
|
||||
e.uid = real.clone();
|
||||
e.node = node.clone();
|
||||
e.node = landed;
|
||||
}
|
||||
// Where the op said the node goes and where it actually landed can
|
||||
// differ — a conflict re-homes it — so the tree follows the parent
|
||||
@@ -1024,6 +1151,9 @@ impl Core {
|
||||
if let Err(e) = self.db.upsert_node(&node) {
|
||||
warn!(%real, error = %e, "db upsert_node failed after remap");
|
||||
}
|
||||
// A write carried over from the create was made against no revision at
|
||||
// all; the one just created is what it now replaces.
|
||||
self.rebaseline_pending(real, &node);
|
||||
if node.is_folder() {
|
||||
// It was recorded as listed while local (it was empty and had nothing
|
||||
// to enumerate). That still holds: its queued children re-intern under
|
||||
@@ -1184,6 +1314,10 @@ impl Core {
|
||||
format!("{reason}; local changes uploaded as {alt}"),
|
||||
false,
|
||||
);
|
||||
self.events.publish(&[
|
||||
pdfs_core::control::Topic::Files,
|
||||
pdfs_core::control::Topic::Conflicts,
|
||||
]);
|
||||
info!(%uid, name, alt, "queued write landed as a conflict copy");
|
||||
Ok(())
|
||||
}
|
||||
@@ -1403,6 +1537,7 @@ impl Core {
|
||||
|
||||
/// Upload a staged revision of a file the server already knows about.
|
||||
pub(crate) fn drain_revision(&self, op: &PendingOp) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let op_started = Instant::now();
|
||||
let blob = op
|
||||
.blob_path
|
||||
.clone()
|
||||
@@ -1412,6 +1547,16 @@ impl Core {
|
||||
let uid = parse_node_uid(&meta.uid).ok_or("staged write has an unparseable uid")?;
|
||||
|
||||
if let Some(reason) = self.revision_conflict(&uid, &meta)? {
|
||||
// Trashing the file from the mount drops its queued write, but not
|
||||
// from under a worker already holding it. That worker then finds the
|
||||
// node trashed — by us — and would upload the withdrawn bytes as a
|
||||
// conflict copy (a `recovered-…` file in the root when the tree has
|
||||
// already forgotten the node). A write nobody queues any more is
|
||||
// not a conflict.
|
||||
if !self.db.op_exists(op.id)? {
|
||||
debug!(%uid, reason, "queued write withdrawn while it drained; dropping it");
|
||||
return Ok(());
|
||||
}
|
||||
return self.keep_as_conflict_copy(op, &blob, &meta, &uid, &reason);
|
||||
}
|
||||
|
||||
@@ -1448,6 +1593,12 @@ impl Core {
|
||||
// this is on the wire can stop it rather than paying for a revision the
|
||||
// op it queued will immediately replace.
|
||||
let cancel = self.begin_cancellable_upload(&uid);
|
||||
// An unlink that withdrew this op before the flag above existed had no
|
||||
// upload to cancel; the row is the record that it happened.
|
||||
if !self.db.op_exists(op.id)? {
|
||||
debug!(%uid, "queued write withdrawn before it went out; dropping it");
|
||||
return Ok(());
|
||||
}
|
||||
let reader = CountingReader::new(File::open(&blob)?, &guard).with_cancel(cancel.flag());
|
||||
let started = Instant::now();
|
||||
let sent = self.rt.block_on(self.client.upload_new_revision_from(
|
||||
@@ -1494,7 +1645,15 @@ impl Core {
|
||||
self.evict_reader(&uid);
|
||||
self.refresh_after_upload(&uid);
|
||||
self.log_activity(ActivityKind::Upload, &name, "uploaded", true);
|
||||
info!(%uid, len = meta.len, "pending upload landed");
|
||||
// Both times, so a slow drain shows whether the upload itself or the
|
||||
// checks and gap-fill around it are where the time goes.
|
||||
info!(
|
||||
%uid,
|
||||
len = meta.len,
|
||||
upload_ms = took.as_millis() as u64,
|
||||
total_ms = op_started.elapsed().as_millis() as u64,
|
||||
"pending upload landed"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1704,6 +1863,31 @@ fn park_verdict(created_at: i64, now: i64, open: bool) -> ParkVerdict {
|
||||
ParkVerdict::Release
|
||||
}
|
||||
|
||||
/// The retryable error for an op whose name a queued trash has yet to free.
|
||||
fn held_by_queued_trash(name: &str) -> Box<dyn std::error::Error> {
|
||||
format!("{name} is still held by a node whose trash is queued").into()
|
||||
}
|
||||
|
||||
/// How much older than its op a remote node may be and still be the one an
|
||||
/// unanswered call made. The op is queued the moment the call gives up, so the
|
||||
/// node is at most one call's deadline older; the rest is clock skew between
|
||||
/// this machine and Proton.
|
||||
const ADOPT_WINDOW_MS: i64 = 2 * 60 * 1000;
|
||||
|
||||
/// Whether `twin`, found holding the name a queued create wants, is the node an
|
||||
/// earlier, unanswered attempt at that create made: the same kind, created
|
||||
/// around when the op was queued, and for a file still empty, as the mount's
|
||||
/// create leaves it.
|
||||
fn adoptable(is_dir: bool, twin: &Node, op_created_ms: i64) -> bool {
|
||||
if twin.creation_time.saturating_mul(1000) < op_created_ms - ADOPT_WINDOW_MS {
|
||||
return false;
|
||||
}
|
||||
match &twin.kind {
|
||||
NodeKind::Folder => is_dir,
|
||||
NodeKind::File { claimed_size, .. } => !is_dir && *claimed_size == Some(0),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2083,6 +2267,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_fresh_empty_twin_of_the_same_kind_is_adopted() {
|
||||
// The op was queued at t=1000 s, after a create that got no answer.
|
||||
let queued = 1_000_000;
|
||||
let made = |mut node: Node, secs: i64| {
|
||||
node.creation_time = secs;
|
||||
node
|
||||
};
|
||||
assert!(adoptable(false, &made(file_node(0, 0, None), 990), queued));
|
||||
assert!(adoptable(true, &made(folder_node("d", None), 990), queued));
|
||||
// Made long before the op: someone else's file, not our lost create.
|
||||
assert!(!adoptable(false, &made(file_node(0, 0, None), 800), queued));
|
||||
// Our create leaves a file empty; one with content is someone else's.
|
||||
assert!(!adoptable(false, &made(file_node(0, 5, None), 990), queued));
|
||||
// The kind has to match what the op makes.
|
||||
assert!(!adoptable(true, &made(file_node(0, 0, None), 990), queued));
|
||||
assert!(!adoptable(
|
||||
false,
|
||||
&made(folder_node("d", None), 990),
|
||||
queued
|
||||
));
|
||||
}
|
||||
|
||||
fn baseline(mtime: i64, size: u64, rev: Option<&str>) -> Baseline {
|
||||
Baseline {
|
||||
mtime,
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
//! Push notifications for front-ends: the daemon's side of
|
||||
//! [`Request::Subscribe`](pdfs_core::control::Request::Subscribe).
|
||||
//!
|
||||
//! Front-ends used to poll. The app asked for the status every two seconds, the
|
||||
//! tray every three, and pages re-read themselves on a timer, whether or not
|
||||
//! anything had changed. Now each subscription owns a [`Mailbox`] in the
|
||||
//! [`EventHub`]. Code that changes something publishes a [`Topic`], and
|
||||
//! [`run_event_sampler`] watches the few things that change without passing
|
||||
//! through one place (the status, transfers, sync progress) and publishes them
|
||||
//! when they differ.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use parking_lot::{Condvar, Mutex, MutexGuard};
|
||||
use pdfs_core::control::{Event, JobItem, Response as CtlResponse, Topic, TransferItem};
|
||||
use tracing::warn;
|
||||
|
||||
use super::Core;
|
||||
|
||||
/// Most subscriptions served at once. Each holds a control handler for as long
|
||||
/// as it lasts; the app and the tray need one apiece, and the cap keeps a
|
||||
/// misbehaving client from spending the handlers that requests need.
|
||||
const MAX_SUBSCRIBERS: usize = 8;
|
||||
|
||||
/// How long a subscription waits after a change before it sends, so a burst
|
||||
/// (a folder of files trashed at once) goes out as one event.
|
||||
const COALESCE: Duration = Duration::from_millis(150);
|
||||
|
||||
/// How often [`run_event_sampler`] looks at transfers. Status and sync
|
||||
/// progress are looked at every other time.
|
||||
const SAMPLE_INTERVAL: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Every open subscription's mailbox. Shared by every clone of [`Core`],
|
||||
/// on-demand forks included, so a change in any mount reaches every client.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct EventHub {
|
||||
mailboxes: Mutex<Vec<Arc<Mailbox>>>,
|
||||
}
|
||||
|
||||
/// What one subscription has yet to send.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Mailbox {
|
||||
inbox: Mutex<Inbox>,
|
||||
ready: Condvar,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Inbox {
|
||||
topics: BTreeSet<Topic>,
|
||||
/// The newest [`Event::Status`] line. A newer one replaces it unsent: a
|
||||
/// client wants the current status, not every status in between.
|
||||
status: Option<Arc<str>>,
|
||||
/// The newest [`Event::Transfers`] line, replaced the same way.
|
||||
transfers: Option<Arc<str>>,
|
||||
}
|
||||
|
||||
impl Inbox {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.topics.is_empty() && self.status.is_none() && self.transfers.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// `event` as one line of the wire format, newline included.
|
||||
pub(crate) fn event_line(event: &Event) -> Option<Arc<str>> {
|
||||
match serde_json::to_string(event) {
|
||||
Ok(mut line) => {
|
||||
line.push('\n');
|
||||
Some(line.into())
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "events: serialize event failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventHub {
|
||||
/// Tell every subscriber that `topics` changed.
|
||||
pub(crate) fn publish(&self, topics: &[Topic]) {
|
||||
self.deliver(|inbox| inbox.topics.extend(topics.iter().copied()));
|
||||
}
|
||||
|
||||
/// Hand every subscriber a fresh status.
|
||||
fn publish_status(&self, line: Arc<str>) {
|
||||
self.deliver(|inbox| inbox.status = Some(line.clone()));
|
||||
}
|
||||
|
||||
/// Hand every subscriber a fresh transfers snapshot.
|
||||
fn publish_transfers(&self, line: Arc<str>) {
|
||||
self.deliver(|inbox| inbox.transfers = Some(line.clone()));
|
||||
}
|
||||
|
||||
fn deliver(&self, put: impl Fn(&mut Inbox)) {
|
||||
for mailbox in self.mailboxes.lock().iter() {
|
||||
put(&mut mailbox.inbox.lock());
|
||||
mailbox.ready.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a mailbox, or `None` at [`MAX_SUBSCRIBERS`].
|
||||
pub(crate) fn subscribe(&self) -> Option<Arc<Mailbox>> {
|
||||
let mut mailboxes = self.mailboxes.lock();
|
||||
if mailboxes.len() >= MAX_SUBSCRIBERS {
|
||||
return None;
|
||||
}
|
||||
let mailbox = Arc::new(Mailbox::default());
|
||||
mailboxes.push(mailbox.clone());
|
||||
Some(mailbox)
|
||||
}
|
||||
|
||||
pub(crate) fn unsubscribe(&self, mailbox: &Arc<Mailbox>) {
|
||||
self.mailboxes
|
||||
.lock()
|
||||
.retain(|other| !Arc::ptr_eq(other, mailbox));
|
||||
}
|
||||
|
||||
fn has_subscribers(&self) -> bool {
|
||||
!self.mailboxes.lock().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Mailbox {
|
||||
/// Wait up to `timeout` for something to send, then take it as wire lines:
|
||||
/// the status first, then transfers, then the changed topics. Empty when
|
||||
/// nothing arrived in time.
|
||||
pub(crate) fn take(&self, timeout: Duration) -> Vec<Arc<str>> {
|
||||
let mut inbox = self.inbox.lock();
|
||||
if inbox.is_empty() {
|
||||
self.ready.wait_for(&mut inbox, timeout);
|
||||
if inbox.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
// Let the rest of a burst arrive before sending the first of it.
|
||||
MutexGuard::unlocked(&mut inbox, || std::thread::sleep(COALESCE));
|
||||
}
|
||||
let mut lines = Vec::new();
|
||||
lines.extend(inbox.status.take());
|
||||
lines.extend(inbox.transfers.take());
|
||||
if !inbox.topics.is_empty() {
|
||||
let topics = std::mem::take(&mut inbox.topics).into_iter().collect();
|
||||
lines.extend(event_line(&Event::Changed { topics }));
|
||||
}
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`Event::Transfers`] line for a snapshot.
|
||||
pub(crate) fn transfers_line(items: Vec<TransferItem>, jobs: Vec<JobItem>) -> Option<Arc<str>> {
|
||||
event_line(&Event::Transfers { items, jobs })
|
||||
}
|
||||
|
||||
/// Watch what changes without a single place to publish it from, and publish it
|
||||
/// when it differs from the last look: the status (queue counts, cache use,
|
||||
/// online, pause), the transfers snapshot, and the synced folders with their
|
||||
/// live progress. Looks only while someone is subscribed.
|
||||
pub(crate) fn run_event_sampler(core: Core, username: String, mountpoint: std::path::PathBuf) {
|
||||
let mut transfers: Option<Arc<str>> = None;
|
||||
let mut status: Option<String> = None;
|
||||
let mut queue: Option<String> = None;
|
||||
let mut locations: Option<String> = None;
|
||||
let mut tick = 0u32;
|
||||
while core.shutdown.sleep(SAMPLE_INTERVAL) {
|
||||
if !core.events.has_subscribers() {
|
||||
// A new subscription starts from a snapshot of its own, so there is
|
||||
// nothing to compare against until then.
|
||||
transfers = None;
|
||||
status = None;
|
||||
queue = None;
|
||||
locations = None;
|
||||
continue;
|
||||
}
|
||||
let line = transfers_line(core.transfers.snapshot(), core.jobs_snapshot());
|
||||
if line.is_some() && line != transfers {
|
||||
transfers = line.clone();
|
||||
if let Some(line) = line {
|
||||
core.events.publish_transfers(line);
|
||||
}
|
||||
}
|
||||
|
||||
tick = tick.wrapping_add(1);
|
||||
if !tick.is_multiple_of(2) {
|
||||
continue;
|
||||
}
|
||||
let now = super::control::status_response(&core, &username, &mountpoint);
|
||||
let (key, queue_key) = status_keys(&now);
|
||||
if status.as_ref().is_some_and(|last| *last != key)
|
||||
&& let Some(line) = event_line(&Event::Status(Box::new(now)))
|
||||
{
|
||||
core.events.publish_status(line);
|
||||
}
|
||||
status = Some(key);
|
||||
if queue.as_ref().is_some_and(|last| *last != queue_key) {
|
||||
core.events.publish(&[Topic::Queue]);
|
||||
}
|
||||
queue = Some(queue_key);
|
||||
|
||||
if let Ok(now) = core.list_locations() {
|
||||
let key = serde_json::to_string(&now).unwrap_or_default();
|
||||
if locations.as_ref().is_some_and(|last| *last != key) {
|
||||
core.events.publish(&[Topic::Locations]);
|
||||
}
|
||||
locations = Some(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What makes two statuses different enough to send, and what makes their
|
||||
/// queues different. The age of the oldest staged write is left out of both:
|
||||
/// it grows every second while anything is staged, which is not news.
|
||||
fn status_keys(status: &CtlResponse) -> (String, String) {
|
||||
let CtlResponse::Status {
|
||||
parked_uploads,
|
||||
failing_ops,
|
||||
failing_error,
|
||||
staged_bytes,
|
||||
paused,
|
||||
paused_until,
|
||||
username,
|
||||
mountpoint,
|
||||
pinned,
|
||||
used,
|
||||
budget,
|
||||
online,
|
||||
pending_uploads,
|
||||
pending_changes,
|
||||
..
|
||||
} = status
|
||||
else {
|
||||
return (String::new(), String::new());
|
||||
};
|
||||
let queue = format!(
|
||||
"{pending_uploads}/{pending_changes}/{parked_uploads}/{failing_ops}/{failing_error:?}"
|
||||
);
|
||||
let key = format!(
|
||||
"{queue}/{staged_bytes}/{paused}/{paused_until:?}/{username}/{mountpoint}/{pinned}/\
|
||||
{used}/{budget}/{online}"
|
||||
);
|
||||
(key, queue)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn changed(line: &str) -> Vec<Topic> {
|
||||
match serde_json::from_str::<Event>(line.trim()) {
|
||||
Ok(Event::Changed { topics }) => topics,
|
||||
other => panic!("not a change event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_burst_of_changes_arrives_as_one_event() {
|
||||
let hub = EventHub::default();
|
||||
let mailbox = hub.subscribe().expect("room for a subscriber");
|
||||
hub.publish(&[Topic::Trash, Topic::Files]);
|
||||
hub.publish(&[Topic::Files]);
|
||||
let lines = mailbox.take(Duration::from_secs(1));
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert_eq!(changed(&lines[0]), vec![Topic::Files, Topic::Trash]);
|
||||
assert!(mailbox.take(Duration::from_millis(10)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newer_status_replaces_one_not_yet_sent() {
|
||||
let hub = EventHub::default();
|
||||
let mailbox = hub.subscribe().expect("room for a subscriber");
|
||||
hub.publish_status("first\n".into());
|
||||
hub.publish_status("second\n".into());
|
||||
assert_eq!(
|
||||
mailbox.take(Duration::from_secs(1)),
|
||||
vec![Arc::<str>::from("second\n")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_subscription_gets_nothing_more() {
|
||||
let hub = EventHub::default();
|
||||
let mailbox = hub.subscribe().expect("room for a subscriber");
|
||||
hub.unsubscribe(&mailbox);
|
||||
hub.publish(&[Topic::Files]);
|
||||
assert!(mailbox.take(Duration::from_millis(10)).is_empty());
|
||||
assert!(!hub.has_subscribers());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscriptions_are_capped() {
|
||||
let hub = EventHub::default();
|
||||
let open: Vec<_> = (0..MAX_SUBSCRIBERS)
|
||||
.map(|_| hub.subscribe().expect("room for a subscriber"))
|
||||
.collect();
|
||||
assert!(hub.subscribe().is_none());
|
||||
hub.unsubscribe(&open[0]);
|
||||
assert!(hub.subscribe().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_age_of_a_staged_write_is_not_a_change() {
|
||||
let status = |staged_oldest_secs| CtlResponse::Status {
|
||||
parked_uploads: 0,
|
||||
failing_ops: 0,
|
||||
failing_error: None,
|
||||
staged_bytes: 10,
|
||||
staged_oldest_secs,
|
||||
paused: false,
|
||||
paused_until: None,
|
||||
username: "u".into(),
|
||||
mountpoint: "/m".into(),
|
||||
pinned: 0,
|
||||
used: 0,
|
||||
budget: 0,
|
||||
pins: Vec::new(),
|
||||
online: true,
|
||||
pending_uploads: 1,
|
||||
pending_changes: 0,
|
||||
};
|
||||
assert_eq!(status_keys(&status(5)), status_keys(&status(6)));
|
||||
}
|
||||
}
|
||||
@@ -290,7 +290,7 @@ impl Filesystem for ProtonFs {
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (uid, mtime, fsize, is_video) = {
|
||||
let (uid, mtime, fsize, provisional, is_video) = {
|
||||
let st = self.core.state();
|
||||
match st.entries.get(&ino.0) {
|
||||
Some(e) if e.node.is_file() => {
|
||||
@@ -300,14 +300,24 @@ impl Filesystem for ProtonFs {
|
||||
};
|
||||
let is_video =
|
||||
PhotoKind::classify(Some(&e.node.name), media_type) == PhotoKind::Video;
|
||||
let fsize = self
|
||||
.core
|
||||
.pending
|
||||
.lock()
|
||||
.get(&e.uid)
|
||||
.map(|p| p.meta.len)
|
||||
.unwrap_or_else(|| node_size(&e.node));
|
||||
(e.uid.clone(), e.node.modification_time, fsize, is_video)
|
||||
let queued = self.core.pending.lock().get(&e.uid).map(|p| p.meta.len);
|
||||
// Still the cheap enumeration's ciphertext size (B12).
|
||||
let provisional = queued.is_none()
|
||||
&& matches!(
|
||||
&e.node.kind,
|
||||
NodeKind::File {
|
||||
claimed_size: None,
|
||||
..
|
||||
}
|
||||
);
|
||||
let fsize = queued.unwrap_or_else(|| node_size(&e.node));
|
||||
(
|
||||
e.uid.clone(),
|
||||
e.node.modification_time,
|
||||
fsize,
|
||||
provisional,
|
||||
is_video,
|
||||
)
|
||||
}
|
||||
Some(_) => {
|
||||
reply.error(Errno::EISDIR);
|
||||
@@ -330,6 +340,13 @@ impl Filesystem for ProtonFs {
|
||||
// races with nothing. FUSE does not require replies in request order.
|
||||
let core = self.core.clone();
|
||||
self.core.workers.run(Lane::Transfer, "read", move || {
|
||||
// The revision disagrees with a provisional size, and B84 fails
|
||||
// such a read outright, so settle the size first. On the network,
|
||||
// hence here and not above (bugs.md B100).
|
||||
let fsize = match provisional {
|
||||
true => core.resolve_provisional_size(ino.0, &uid).unwrap_or(fsize),
|
||||
false => fsize,
|
||||
};
|
||||
match core.read_range(&uid, mtime, fsize, offset, size as u64, cache_blocks) {
|
||||
Ok(bytes) => reply.data(&bytes),
|
||||
Err(e) => reply.error(e),
|
||||
@@ -425,10 +442,7 @@ impl Filesystem for ProtonFs {
|
||||
reply.error(Errno::EBADF);
|
||||
return;
|
||||
};
|
||||
let end = offset + data.len() as u64;
|
||||
aw.written.add(offset, end);
|
||||
aw.len = aw.len.max(end);
|
||||
aw.dirty = true;
|
||||
aw.record_write(offset, offset + data.len() as u64);
|
||||
let len = aw.len;
|
||||
st.set_size(ino.0, len);
|
||||
len
|
||||
@@ -1193,7 +1207,7 @@ impl ProtonFs {
|
||||
/// open.
|
||||
fn serve_open(&self, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) {
|
||||
let write_requested = flags.acc_mode() != OpenAccMode::O_RDONLY;
|
||||
let (uid, base_mtime, base_size, base_revision_id) = {
|
||||
let (uid, base_mtime, base_size, provisional, base_revision_id) = {
|
||||
let mut st = self.core.state();
|
||||
match st.entries.get_mut(&ino.0) {
|
||||
Some(e) if e.node.is_file() => {
|
||||
@@ -1206,6 +1220,14 @@ impl ProtonFs {
|
||||
e.uid.clone(),
|
||||
e.node.modification_time,
|
||||
node_size(&e.node),
|
||||
// Still the cheap enumeration's ciphertext size (B12).
|
||||
matches!(
|
||||
&e.node.kind,
|
||||
NodeKind::File {
|
||||
claimed_size: None,
|
||||
..
|
||||
}
|
||||
),
|
||||
node_revision_id(&e.node),
|
||||
)
|
||||
}
|
||||
@@ -1251,6 +1273,21 @@ impl ProtonFs {
|
||||
// `reply` is consumed by whichever arm answers, so the loop yields an
|
||||
// outcome and the answer is sent once, after it.
|
||||
let outcome = 'install: {
|
||||
// The handle starts at the base's size. A provisional one is the
|
||||
// ciphertext size, so the file would grow by the encryption overhead
|
||||
// and reading the base would fail (bugs.md B100). A queued revision
|
||||
// stamps a real size, so this only goes to the network for a file
|
||||
// the listing has not sized yet.
|
||||
let base_size = match provisional && self.core.pending_blob(&uid).is_none() {
|
||||
false => base_size,
|
||||
true => match self.core.resolve_provisional_size(ino.0, &uid) {
|
||||
Some(size) => size,
|
||||
None => {
|
||||
error!(%uid, "could not settle the size of the base for a write open");
|
||||
break 'install Err(Errno::EIO);
|
||||
}
|
||||
},
|
||||
};
|
||||
for attempt in 0..OPEN_BASE_ATTEMPTS {
|
||||
let pending_base = self.core.pending.lock().get(&uid).cloned();
|
||||
if pending_base.as_ref().is_some_and(|p| !p.meta.complete) {
|
||||
@@ -1427,6 +1464,12 @@ impl ProtonFs {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = self.core.ensure_children(parent) {
|
||||
// The parent was looked up a moment ago, so ENOENT here means it
|
||||
// left the tree in between. Log it: the caller only sees "no such
|
||||
// file" on a create.
|
||||
if e == Errno::ENOENT {
|
||||
warn!(parent, name, "create: the parent folder is no longer known");
|
||||
}
|
||||
reply.error(e);
|
||||
return;
|
||||
}
|
||||
@@ -1435,6 +1478,10 @@ impl ProtonFs {
|
||||
match st.entries.get(&parent) {
|
||||
Some(e) => e.uid.clone(),
|
||||
None => {
|
||||
warn!(
|
||||
parent,
|
||||
name, "create: the parent folder left the tree during its listing"
|
||||
);
|
||||
reply.error(Errno::ENOENT);
|
||||
return;
|
||||
}
|
||||
@@ -1454,47 +1501,55 @@ impl ProtonFs {
|
||||
//
|
||||
// A parent that is itself still queued forces the same path even when we
|
||||
// are online: the API has no folder to put this in yet.
|
||||
let node =
|
||||
if !transient && self.core.online.load(Ordering::Relaxed) && !is_local_uid(&parent_uid)
|
||||
{
|
||||
if let Err(error) = self.core.require_uid_writable(&parent_uid) {
|
||||
reply.error(error);
|
||||
//
|
||||
// A create that set out online and lost the network on the way lands in
|
||||
// the queue too, so a Wi-Fi drop costs the caller a wait, never an EIO.
|
||||
let minted = if !transient && self.core.is_online() && !is_local_uid(&parent_uid) {
|
||||
if let Err(error) = self.core.require_uid_writable(&parent_uid) {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
// Create an empty file on the remote so it has a real uid immediately;
|
||||
// written bytes are buffered and sealed as a new revision on close.
|
||||
match self.core.block_on_bounded(self.core.client.upload_file(
|
||||
&parent_uid,
|
||||
name,
|
||||
media_type_for(name),
|
||||
b"",
|
||||
)) {
|
||||
Ok(new_uid) => match self.core.fetch_node(&new_uid) {
|
||||
Ok(n) => Some(n),
|
||||
// The file exists remotely but we could not read it back.
|
||||
// The queued create finds it by name and adopts it.
|
||||
Err(e) if e == Errno::EIO && !self.core.is_online() => None,
|
||||
Err(e) => {
|
||||
reply.error(e);
|
||||
return;
|
||||
}
|
||||
},
|
||||
Err(e) if self.core.lost_link(&e, "create") => None,
|
||||
Err(e) => {
|
||||
error!(%parent_uid, name, error = %e, "create file failed");
|
||||
reply.error(Errno::EIO);
|
||||
return;
|
||||
}
|
||||
// Create an empty file on the remote so it has a real uid immediately;
|
||||
// written bytes are buffered and sealed as a new revision on close.
|
||||
let new_uid = match self.core.rt.block_on(self.core.client.upload_file(
|
||||
&parent_uid,
|
||||
name,
|
||||
media_type_for(name),
|
||||
b"",
|
||||
)) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
error!(%parent_uid, name, error = %e, "create file failed");
|
||||
reply.error(Errno::EIO);
|
||||
return;
|
||||
}
|
||||
};
|
||||
match self.core.fetch_node(&new_uid) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
reply.error(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let node = match minted {
|
||||
Some(node) => node,
|
||||
None => match self
|
||||
.core
|
||||
.queue_local_node(&parent_uid, name, false, transient)
|
||||
{
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
reply.error(e);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
match self
|
||||
.core
|
||||
.queue_local_node(&parent_uid, name, false, transient)
|
||||
{
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
reply.error(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
let new_uid = node.uid.clone();
|
||||
// The base this handle writes over is the node as it actually exists —
|
||||
// the empty file the server just minted — so its modification time comes
|
||||
@@ -1584,39 +1639,43 @@ impl ProtonFs {
|
||||
// As in `create`: offline — or under a parent that is itself still
|
||||
// queued — the folder becomes a placeholder that the drain turns into a
|
||||
// real one (offline.md Phase 3b).
|
||||
let node = if self.core.online.load(Ordering::Relaxed) && !is_local_uid(&parent_uid) {
|
||||
// Losing the network on the way queues it the same way.
|
||||
let minted = if self.core.is_online() && !is_local_uid(&parent_uid) {
|
||||
if let Err(error) = self.core.require_uid_writable(&parent_uid) {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
let new_uid =
|
||||
match self
|
||||
.core
|
||||
.rt
|
||||
.block_on(self.core.client.create_folder(&parent_uid, name, now))
|
||||
{
|
||||
Ok(u) => u,
|
||||
match self
|
||||
.core
|
||||
.block_on_bounded(self.core.client.create_folder(&parent_uid, name, now))
|
||||
{
|
||||
Ok(new_uid) => match self.core.fetch_node(&new_uid) {
|
||||
Ok(n) => Some(n),
|
||||
Err(e) if e == Errno::EIO && !self.core.is_online() => None,
|
||||
Err(e) => {
|
||||
error!(%parent_uid, name, error = %e, "create folder failed");
|
||||
reply.error(Errno::EIO);
|
||||
reply.error(e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
match self.core.fetch_node(&new_uid) {
|
||||
Ok(n) => n,
|
||||
},
|
||||
Err(e) if self.core.lost_link(&e, "mkdir") => None,
|
||||
Err(e) => {
|
||||
reply.error(e);
|
||||
error!(%parent_uid, name, error = %e, "create folder failed");
|
||||
reply.error(Errno::EIO);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match self.core.queue_local_node(&parent_uid, name, true, false) {
|
||||
None
|
||||
};
|
||||
let node = match minted {
|
||||
Some(node) => node,
|
||||
None => match self.core.queue_local_node(&parent_uid, name, true, false) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
reply.error(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
let mut st = self.core.state();
|
||||
let local = is_local_uid(&node.uid);
|
||||
@@ -1706,12 +1765,18 @@ impl ProtonFs {
|
||||
if let Some(uid) = unlinked_uid
|
||||
&& release_can_discard_unlinked(&self.core.db, &uid)
|
||||
{
|
||||
// Ops withdrawn at unlink left the bytes this handle read behind,
|
||||
// owned by no op any more; they go with the last handle.
|
||||
let withdrawn = self.core.pending_blob(&uid);
|
||||
if let Err(error) = self.core.discard_queued_ops(&uid) {
|
||||
warn!(%uid, ?error, "queued-op cleanup failed; retaining unlinked state");
|
||||
} else {
|
||||
if let Err(error) = self.core.db.delete_node(&uid) {
|
||||
warn!(%uid, %error, "deleting released unlinked node state failed");
|
||||
}
|
||||
if let Some(blob) = withdrawn {
|
||||
self.core.cache.discard_staged(&blob);
|
||||
}
|
||||
self.core.cache.evict(&uid);
|
||||
self.core.evict_reader(&uid);
|
||||
}
|
||||
@@ -1932,12 +1997,11 @@ impl ProtonFs {
|
||||
// take the synchronous path, so a genuine API refusal (permissions, a
|
||||
// name clash) surfaces to the caller instead of becoming a queued op
|
||||
// that can only ever conflict.
|
||||
if rename_needs_queue(
|
||||
self.core.online.load(Ordering::Relaxed),
|
||||
is_local_uid(&new_parent_uid),
|
||||
newparent != parent,
|
||||
newname != name,
|
||||
) {
|
||||
// The queued rename, as a closure: the network going away under the
|
||||
// synchronous path below lands here too, with whatever half already
|
||||
// reached Drive. The op records the end state, so the drain finishes
|
||||
// the rest.
|
||||
let queue_it = |reply: ReplyEmpty| {
|
||||
let queued = if replacement_authorized {
|
||||
self.core.queue_rename_authorized(
|
||||
ino,
|
||||
@@ -1978,6 +2042,14 @@ impl ProtonFs {
|
||||
reply.error(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
if rename_needs_queue(
|
||||
self.core.is_online(),
|
||||
is_local_uid(&new_parent_uid),
|
||||
newparent != parent,
|
||||
newname != name,
|
||||
) {
|
||||
queue_it(reply);
|
||||
return;
|
||||
}
|
||||
// Rename first if both halves change. Moving first makes the encrypted
|
||||
@@ -1996,11 +2068,12 @@ impl ProtonFs {
|
||||
return;
|
||||
}
|
||||
if newname != name
|
||||
&& let Err(e) = self
|
||||
.core
|
||||
.rt
|
||||
.block_on(self.core.client.rename_node(&uid, newname, None))
|
||||
&& let Err(e) = self.core.rename_remote(&uid, newname)
|
||||
{
|
||||
if self.core.lost_link(&e, "rename") {
|
||||
queue_it(reply);
|
||||
return;
|
||||
}
|
||||
error!(%uid, error = %e, "rename failed");
|
||||
self.core.restore_replaced(victim.as_ref(), newname);
|
||||
reply.error(Errno::EIO);
|
||||
@@ -2011,8 +2084,7 @@ impl ProtonFs {
|
||||
let moved = loop {
|
||||
match self
|
||||
.core
|
||||
.rt
|
||||
.block_on(self.core.client.move_node(&uid, &new_parent_uid))
|
||||
.block_on_bounded(self.core.client.move_node(&uid, &new_parent_uid))
|
||||
{
|
||||
Ok(()) => break Ok(()),
|
||||
Err(e)
|
||||
@@ -2031,6 +2103,10 @@ impl ProtonFs {
|
||||
}
|
||||
};
|
||||
if let Err(e) = moved {
|
||||
if self.core.lost_link(&e, "move") {
|
||||
queue_it(reply);
|
||||
return;
|
||||
}
|
||||
error!(%uid, attempts, error = %e, "move after rename failed");
|
||||
// The rename half landed in the source directory.
|
||||
let mut state = self.core.state();
|
||||
@@ -2092,7 +2168,12 @@ impl ProtonFs {
|
||||
// it just means its queued creation is no longer wanted. This works
|
||||
// offline, which the remote path below cannot (offline.md Phase 3b).
|
||||
if is_local_uid(&uid) {
|
||||
if !open_now && let Err(error) = self.core.discard_queued_ops(&uid) {
|
||||
let dropped = if open_now {
|
||||
self.core.withdraw_queued_ops(&uid)
|
||||
} else {
|
||||
self.core.discard_queued_ops(&uid)
|
||||
};
|
||||
if let Err(error) = dropped {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
@@ -2103,12 +2184,14 @@ impl ProtonFs {
|
||||
}
|
||||
// Offline: queue it. Trashing is the one mutation a user expects to work
|
||||
// regardless — the file is gone from their point of view the moment the
|
||||
// command returns (offline.md Phase 3b).
|
||||
if !self.core.online.load(Ordering::Relaxed) {
|
||||
match self.core.queue_trash(&uid, name) {
|
||||
Ok(()) => reply.ok(),
|
||||
Err(e) => reply.error(e),
|
||||
}
|
||||
// command returns (offline.md Phase 3b). The same when the network goes
|
||||
// away under the remote call below.
|
||||
let queue_it = |reply: ReplyEmpty| match self.core.queue_trash(&uid, name) {
|
||||
Ok(()) => reply.ok(),
|
||||
Err(e) => reply.error(e),
|
||||
};
|
||||
if !self.core.is_online() {
|
||||
queue_it(reply);
|
||||
return;
|
||||
}
|
||||
if let Err(e) = self.core.require_uid_writable(&uid) {
|
||||
@@ -2117,10 +2200,13 @@ impl ProtonFs {
|
||||
}
|
||||
if let Err(e) = self
|
||||
.core
|
||||
.rt
|
||||
.block_on(self.core.client.trash_nodes(std::slice::from_ref(&uid)))
|
||||
.block_on_bounded(self.core.client.trash_nodes(std::slice::from_ref(&uid)))
|
||||
.and_then(batch::into_unit)
|
||||
{
|
||||
if self.core.lost_link(&e, "trash") {
|
||||
queue_it(reply);
|
||||
return;
|
||||
}
|
||||
error!(%uid, error = %e, "trash failed");
|
||||
self.core
|
||||
.log_activity(ActivityKind::Trash, name, e.to_string(), false);
|
||||
@@ -2131,7 +2217,13 @@ impl ProtonFs {
|
||||
// Otherwise an EIO response still made the path disappear locally.
|
||||
self.core.hidden.lock().insert(uid.clone());
|
||||
self.core.state().forget_or_unlink(&uid);
|
||||
if !open_now {
|
||||
if open_now {
|
||||
// The open handle keeps the staged bytes; the drain must not send
|
||||
// them to a node that is already in the trash (docs/BUGS.md B106).
|
||||
if let Err(error) = self.core.withdraw_queued_ops(&uid) {
|
||||
warn!(%uid, ?error, "remote trash landed but queued-op cleanup failed");
|
||||
}
|
||||
} else {
|
||||
if let Err(error) = self.core.discard_queued_ops(&uid) {
|
||||
warn!(%uid, ?error, "remote trash landed but queued-op cleanup failed");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
//! Noticing that the network went away mid-session, and that it came back.
|
||||
//!
|
||||
//! The mount used to learn it was offline only at startup: a daemon that had
|
||||
//! connected once kept `online` true for the rest of its life, so a Wi-Fi drop
|
||||
//! turned every create, mkdir, delete and rename into a two-minute wait for an
|
||||
//! `EIO`, while the queue that exists for exactly this sat unused. Now any
|
||||
//! remote call a handler waits on reports a failure to reach Proton through
|
||||
//! [`Core::lost_link`], which flips the mount offline; the handler then takes its
|
||||
//! queued path, and the probe thread here flips it back when Proton answers.
|
||||
|
||||
use std::future::Future;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use parking_lot::{Condvar, Mutex};
|
||||
use proton_drive_rs::proton_sdk::api::ResponseCode;
|
||||
use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::{Core, ONLINE_PROBE_MAX, ONLINE_PROBE_MIN, ROOT_INO};
|
||||
|
||||
/// How long a remote call a FUSE handler is waiting on may take before the link
|
||||
/// is treated as down.
|
||||
///
|
||||
/// The SDK's own budget for one request is four attempts of up to 30 seconds
|
||||
/// each, so a dead link held the caller for about two minutes. A metadata call
|
||||
/// that has had no answer in 20 seconds is not going to be one the user wants to
|
||||
/// keep waiting for; queueing the change is the better answer.
|
||||
pub(crate) const INTERACTIVE_CALL_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
/// How long one probe may wait for an answer. A probe is a single small request,
|
||||
/// and a probe stuck in the SDK's retry budget would delay noticing the link is
|
||||
/// back by as long as it waits.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// First delay between probes after a drop seen mid-session, and the ceiling it
|
||||
/// doubles to while the outage is young. A blip should end in seconds, not in
|
||||
/// the minutes [`ONLINE_PROBE_MAX`] allows.
|
||||
const PROBE_AFTER_DROP: Duration = Duration::from_secs(2);
|
||||
const PROBE_YOUNG_MAX: Duration = Duration::from_secs(30);
|
||||
/// How long an outage counts as young. Past it the probe backs off to
|
||||
/// [`ONLINE_PROBE_MAX`], because a laptop can sit offline for days and each
|
||||
/// probe is a real API round trip.
|
||||
const YOUNG_OUTAGE: Duration = Duration::from_secs(10 * 60);
|
||||
|
||||
/// How often the idle probe thread checks for teardown while the link is up.
|
||||
const IDLE_CHECK: Duration = Duration::from_secs(1);
|
||||
|
||||
/// The shared half of the online flag: when the link went down, and a condvar
|
||||
/// that fires on every change so waiters need not poll.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Link {
|
||||
lost_at: Mutex<Option<Instant>>,
|
||||
changed: Condvar,
|
||||
/// Folders listed from the DB alone while offline, to relist once back.
|
||||
pub(crate) stale_listings: Mutex<std::collections::HashSet<u64>>,
|
||||
}
|
||||
|
||||
/// Whether a failed call failed because Proton could not be reached, rather
|
||||
/// than because it answered and refused.
|
||||
///
|
||||
/// A transport error is the network. So is a request timeout: the SDK reports
|
||||
/// Proton's own "try again" codes that way, and [`no_answer`] reports our
|
||||
/// deadline that way. Anything else Proton actually said, and says again on a
|
||||
/// retry, so it must reach the caller rather than be queued.
|
||||
pub(crate) fn is_network_error(e: &ProtonError) -> bool {
|
||||
match e {
|
||||
ProtonError::Transport(_) => true,
|
||||
ProtonError::Api(api) => {
|
||||
matches!(
|
||||
api.code,
|
||||
ResponseCode::RequestTimeout | ResponseCode::Timeout
|
||||
)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The error a call reports when it runs past `after` without an answer.
|
||||
pub(crate) fn no_answer(after: Duration) -> ProtonError {
|
||||
ProtonError::Api(ProtonApiError {
|
||||
code: ResponseCode::RequestTimeout,
|
||||
http_status: 408,
|
||||
message: format!("no answer within {}s", after.as_secs()),
|
||||
details: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Run a remote call, giving up after [`INTERACTIVE_CALL_TIMEOUT`].
|
||||
pub(crate) async fn bounded<T>(
|
||||
call: impl Future<Output = Result<T, ProtonError>>,
|
||||
) -> Result<T, ProtonError> {
|
||||
match tokio::time::timeout(INTERACTIVE_CALL_TIMEOUT, call).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(no_answer(INTERACTIVE_CALL_TIMEOUT)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The next delay between probes, given the current one and how long the
|
||||
/// outage has lasted.
|
||||
fn next_probe_delay(delay: Duration, outage: Duration) -> Duration {
|
||||
let cap = if outage < YOUNG_OUTAGE {
|
||||
PROBE_YOUNG_MAX
|
||||
} else {
|
||||
ONLINE_PROBE_MAX
|
||||
};
|
||||
(delay * 2).min(cap)
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Whether the mount currently believes Proton is reachable.
|
||||
pub(crate) fn is_online(&self) -> bool {
|
||||
self.online.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Run a remote call a caller is waiting on, giving up after
|
||||
/// [`INTERACTIVE_CALL_TIMEOUT`]. The timer is built inside the runtime: a
|
||||
/// FUSE worker has no reactor of its own.
|
||||
pub(crate) fn block_on_bounded<T>(
|
||||
&self,
|
||||
call: impl Future<Output = Result<T, ProtonError>>,
|
||||
) -> Result<T, ProtonError> {
|
||||
self.rt.block_on(bounded(call))
|
||||
}
|
||||
|
||||
/// Report a failed remote call. Returns whether it failed for want of a
|
||||
/// network, in which case the mount is now offline and the caller should
|
||||
/// take its queued path instead of failing.
|
||||
pub(crate) fn lost_link(&self, e: &ProtonError, what: &str) -> bool {
|
||||
if !is_network_error(e) {
|
||||
return false;
|
||||
}
|
||||
self.mark_offline(what, e);
|
||||
true
|
||||
}
|
||||
|
||||
/// Flip the mount offline and wake the probe. Only the first caller of an
|
||||
/// outage logs it; the rest are the same outage.
|
||||
pub(crate) fn mark_offline(&self, what: &str, error: &dyn std::fmt::Display) {
|
||||
let mut lost_at = self.link.lost_at.lock();
|
||||
if self.online.swap(false, Ordering::Relaxed) {
|
||||
*lost_at = Some(Instant::now());
|
||||
warn!(during = what, %error,
|
||||
"lost the connection to Proton; queueing changes until it returns");
|
||||
self.link.changed.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
/// Flip the mount back online, wake everything waiting for it, and let the
|
||||
/// queue drain.
|
||||
fn mark_online(&self) {
|
||||
{
|
||||
let mut lost_at = self.link.lost_at.lock();
|
||||
self.online.store(true, Ordering::Relaxed);
|
||||
match lost_at.take() {
|
||||
Some(since) => {
|
||||
info!(
|
||||
offline_secs = since.elapsed().as_secs(),
|
||||
"connection to Proton is back"
|
||||
)
|
||||
}
|
||||
None => info!("back online"),
|
||||
}
|
||||
self.link.changed.notify_all();
|
||||
}
|
||||
self.relist_offline_listings();
|
||||
// Anything written while offline is queued and waiting on exactly this,
|
||||
// including ops that failed on the way down and are sitting out a
|
||||
// backoff sized for a server that keeps refusing.
|
||||
if let Err(e) = self.db.retry_failed_ops_now(crate::now_millis()) {
|
||||
warn!(error = %e, "moving backed-off ops up after a reconnect failed");
|
||||
}
|
||||
self.wake_drain();
|
||||
}
|
||||
|
||||
/// Wait up to `budget` for the link to come back. Returns whether it is up.
|
||||
pub(crate) fn wait_online(&self, budget: Duration) -> bool {
|
||||
let deadline = Instant::now() + budget;
|
||||
let mut lost_at = self.link.lost_at.lock();
|
||||
while !self.is_online() {
|
||||
if self.shutdown.is_stopping()
|
||||
|| self
|
||||
.link
|
||||
.changed
|
||||
.wait_until(&mut lost_at, deadline)
|
||||
.timed_out()
|
||||
{
|
||||
return self.is_online();
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Wait out `interval` before a background poll of Proton, and while the
|
||||
/// link is down, until it is back. Returns as soon as it comes back, so the
|
||||
/// poll catches up on what changed during the outage at once instead of
|
||||
/// one interval later. A poll into a dead link would only wait out the
|
||||
/// SDK's retries and fail.
|
||||
pub(crate) async fn next_poll(&self, interval: Duration) {
|
||||
let due = Instant::now() + interval;
|
||||
let mut was_offline = false;
|
||||
loop {
|
||||
if !self.is_online() {
|
||||
was_offline = true;
|
||||
} else if was_offline || Instant::now() >= due {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(IDLE_CHECK).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Watch the link for the life of the mount: sleep while it is up, and
|
||||
/// probe it back while it is down (offline.md Phase 1).
|
||||
///
|
||||
/// A mount that started from the cache is down from the first moment; one
|
||||
/// that started online goes down when [`Core::mark_offline`] says so.
|
||||
pub(crate) fn run_online_probe(&self) {
|
||||
// Only an offline start never had a chance to repair its share id.
|
||||
let mut repair_share_id = !self.is_online();
|
||||
loop {
|
||||
{
|
||||
let mut lost_at = self.link.lost_at.lock();
|
||||
while self.is_online() {
|
||||
if self.shutdown.is_stopping() {
|
||||
return;
|
||||
}
|
||||
self.link.changed.wait_for(&mut lost_at, IDLE_CHECK);
|
||||
}
|
||||
}
|
||||
if !self.probe_until_online() {
|
||||
return;
|
||||
}
|
||||
if std::mem::take(&mut repair_share_id) {
|
||||
self.repair_primary_share_id(&self.primary_root_uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe until Proton answers, then flip the mount online. Returns `false`
|
||||
/// if the daemon is stopping.
|
||||
fn probe_until_online(&self) -> bool {
|
||||
let started = self.link.lost_at.lock().unwrap_or_else(Instant::now);
|
||||
let mut delay = if started.elapsed() < YOUNG_OUTAGE {
|
||||
PROBE_AFTER_DROP
|
||||
} else {
|
||||
ONLINE_PROBE_MIN
|
||||
};
|
||||
loop {
|
||||
if !self.shutdown.sleep(delay) {
|
||||
return false;
|
||||
}
|
||||
let probe = self.rt.block_on(async {
|
||||
match tokio::time::timeout(PROBE_TIMEOUT, self.client.get_my_files_folder()).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(no_answer(PROBE_TIMEOUT)),
|
||||
}
|
||||
});
|
||||
match probe {
|
||||
Ok(root) => {
|
||||
{
|
||||
let mut st = self.state();
|
||||
if let Some(e) = st.entries.get_mut(&ROOT_INO) {
|
||||
e.node = root.clone();
|
||||
}
|
||||
}
|
||||
if let Err(e) = self.db.upsert_node(&root) {
|
||||
warn!(error = %e, "refresh root after reconnect failed");
|
||||
}
|
||||
self.mark_online();
|
||||
return true;
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(error = %e, ?delay, "online probe failed; still offline");
|
||||
delay = next_probe_delay(delay, started.elapsed());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn api(code: ResponseCode) -> ProtonError {
|
||||
ProtonError::Api(ProtonApiError {
|
||||
code,
|
||||
http_status: 422,
|
||||
message: String::new(),
|
||||
details: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_an_unanswered_call_counts_as_a_lost_link() {
|
||||
assert!(is_network_error(&no_answer(INTERACTIVE_CALL_TIMEOUT)));
|
||||
assert!(is_network_error(&api(ResponseCode::Timeout)));
|
||||
assert!(!is_network_error(&api(ResponseCode::AlreadyExists)));
|
||||
assert!(!is_network_error(&api(ResponseCode::InvalidRequirements)));
|
||||
assert!(!is_network_error(&ProtonError::invalid_operation("bug")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_young_outage_is_probed_often_and_an_old_one_rarely() {
|
||||
let young = Duration::from_secs(60);
|
||||
let old = YOUNG_OUTAGE + Duration::from_secs(1);
|
||||
assert_eq!(
|
||||
next_probe_delay(PROBE_AFTER_DROP, young),
|
||||
2 * PROBE_AFTER_DROP
|
||||
);
|
||||
assert_eq!(next_probe_delay(PROBE_YOUNG_MAX, young), PROBE_YOUNG_MAX);
|
||||
assert_eq!(next_probe_delay(PROBE_YOUNG_MAX, old), 2 * PROBE_YOUNG_MAX);
|
||||
assert_eq!(next_probe_delay(ONLINE_PROBE_MAX, old), ONLINE_PROBE_MAX);
|
||||
}
|
||||
}
|
||||
@@ -383,6 +383,7 @@ pub fn mount(
|
||||
shared_generation: Arc::new(AtomicU64::new(0)),
|
||||
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
||||
online: Arc::new(AtomicBool::new(online)),
|
||||
link: Arc::new(Default::default()),
|
||||
sync_paused_until: Arc::new(AtomicI64::new(paused_until)),
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
hidden: Arc::new(Mutex::new(HashSet::new())),
|
||||
@@ -411,11 +412,13 @@ pub fn mount(
|
||||
thumbnail_build_cancel: Arc::new(tokio::sync::Notify::new()),
|
||||
thumbnail_misses: Arc::new(Mutex::new(Default::default())),
|
||||
quota: Arc::new(Mutex::new(None)),
|
||||
quota_refreshing: Arc::new(AtomicBool::new(false)),
|
||||
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
|
||||
size_waiters: Arc::new(Default::default()),
|
||||
notifier: Arc::new(OnceLock::new()),
|
||||
session_live: Arc::new(AtomicBool::new(false)),
|
||||
transfers: TransferRegistry::new(),
|
||||
events: Arc::new(Default::default()),
|
||||
indexing: Arc::new(AtomicBool::new(false)),
|
||||
sync_progress: Arc::new(Mutex::new(HashMap::new())),
|
||||
sync_tx,
|
||||
@@ -483,9 +486,10 @@ pub fn mount(
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
if !online {
|
||||
// Watch the link for the life of the mount: a mount from the cache waits for
|
||||
// the network to come back, and an online one can lose it at any moment and
|
||||
// must notice it coming back just the same.
|
||||
{
|
||||
let core = core.clone();
|
||||
workers.push(
|
||||
std::thread::Builder::new()
|
||||
@@ -568,6 +572,22 @@ pub fn mount(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Publishes what changes without one place to publish it from (status,
|
||||
// transfers, sync progress) to the front-ends subscribed on that socket.
|
||||
{
|
||||
let sampler_core = core.clone();
|
||||
let username = username.clone();
|
||||
let mountpoint = mountpoint.to_path_buf();
|
||||
match std::thread::Builder::new()
|
||||
.name("pdfs-events".into())
|
||||
.spawn(move || events::run_event_sampler(sampler_core, username, mountpoint))
|
||||
{
|
||||
Ok(handle) => workers.push(handle),
|
||||
// Not fatal: front-ends still get every published change, only
|
||||
// not the sampled ones.
|
||||
Err(error) => warn!(error = %error, "could not start the event sampler"),
|
||||
}
|
||||
}
|
||||
// The supervisor watches the pool and answers the systemd watchdog. Started
|
||||
// after the listener, because its liveness probe is a round trip over that
|
||||
// socket.
|
||||
|
||||
@@ -19,7 +19,7 @@ use std::time::{Duration, Instant};
|
||||
use base64::Engine as _;
|
||||
use pdfs_core::control::{
|
||||
FileThumbRequest, MappingProgress, PhotoItem, PhotoKind, PhotoLocation, PhotoThumb, PlaceInfo,
|
||||
ThumbnailBuildStatus, is_raw_image_name, is_thumbnail_image_name,
|
||||
ThumbnailBuildStatus, Topic, is_raw_image_name, is_thumbnail_image_name,
|
||||
};
|
||||
use pdfs_core::db::{self, StoredPhoto};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
@@ -2041,10 +2041,14 @@ impl Core {
|
||||
}
|
||||
let core = self.clone();
|
||||
self.rt.spawn(async move {
|
||||
if let Err(e) = core.refresh_timeline().await {
|
||||
warn!(error = %e, "background timeline refresh failed");
|
||||
}
|
||||
let refreshed = core.refresh_timeline().await;
|
||||
core.timeline_refreshing.store(false, Ordering::SeqCst);
|
||||
// Only a refresh that landed: a front-end re-reading after a failed
|
||||
// one would find the timeline still stale and start another.
|
||||
match refreshed {
|
||||
Ok(_) => core.events.publish(&[Topic::Photos]),
|
||||
Err(e) => warn!(error = %e, "background timeline refresh failed"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,21 @@ pub(super) const REPAIR_MAX: u64 = 512 * 1024 * 1024;
|
||||
/// demand read in the SDK's flat block semaphore.
|
||||
pub(super) const PREFETCH_BUDGET: usize = 8;
|
||||
|
||||
/// How long one read may wait on the network before it fails with `EIO`.
|
||||
///
|
||||
/// The SDK bounds each request, not a read: a block that times out is retried,
|
||||
/// and a read can need several blocks, a block table and a repair. On a link
|
||||
/// that stalled without dropping, one `read` held a FUSE worker for 34 minutes
|
||||
/// and the application behind it with it (`docs/BUGS.md` B107). An error the
|
||||
/// caller can retry beats a process stuck in `D` state.
|
||||
pub(super) const READ_FETCH_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// How long a read of blocks nothing has cached waits for a lost link to come
|
||||
/// back before failing. Long enough to ride out a Wi-Fi blip, short enough
|
||||
/// that a program reading while the laptop is really offline gets its `EIO`
|
||||
/// before the user gives up on it.
|
||||
const READ_OFFLINE_WAIT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Cap on tracked sequential streams. Only reads in progress matter, so the map
|
||||
/// is cleared wholesale rather than aged — losing the ramp costs one read.
|
||||
const MAX_PREFETCH_STREAMS: usize = 256;
|
||||
@@ -364,6 +379,7 @@ impl Core {
|
||||
let result = match self.client.open_revision(uid).await {
|
||||
Ok(reader) => Ok(Arc::new(reader)),
|
||||
Err(e) => {
|
||||
self.lost_link(&e, "open a revision");
|
||||
warn!(%uid, error = %e, "open_revision failed");
|
||||
Err(Errno::EIO)
|
||||
}
|
||||
@@ -649,6 +665,7 @@ impl Core {
|
||||
.store_block_geometry(uid, mtime, fsize, reader.block_sizes());
|
||||
}
|
||||
let bytes = reader.read_at(span.start, span.len).await.map_err(|e| {
|
||||
self.lost_link(&e, "read a block");
|
||||
warn!(%uid, start = span.start, len = span.len, error = %e, "block read failed");
|
||||
Errno::EIO
|
||||
})?;
|
||||
@@ -939,28 +956,67 @@ impl Core {
|
||||
}
|
||||
|
||||
if !misses.is_empty() {
|
||||
let fetched = self.rt.block_on(async {
|
||||
// Resolve the file's keys and block table once, then read every
|
||||
// missing block through the shared reader. Previously each block
|
||||
// called `download_range`, which redid that resolution per block.
|
||||
let reader = self.revision_reader(uid, mtime, fsize).await?;
|
||||
// One deadline for the whole read, including any wait for a lost
|
||||
// link. A fetch that failed because the link went down is tried
|
||||
// once more when it is back, so a blip is a slow read, not an `EIO`.
|
||||
let deadline = Instant::now() + READ_FETCH_TIMEOUT;
|
||||
let mut retried = false;
|
||||
let fetched = loop {
|
||||
if !self.is_online() {
|
||||
let wait =
|
||||
READ_OFFLINE_WAIT.min(deadline.saturating_duration_since(Instant::now()));
|
||||
if !self.wait_online(wait) {
|
||||
debug!(%uid, offset, len, "read of uncached blocks while offline; failing it");
|
||||
return Err(Errno::EIO);
|
||||
}
|
||||
}
|
||||
let fetch = async {
|
||||
// Resolve the file's keys and block table once, then read every
|
||||
// missing block through the shared reader. Previously each block
|
||||
// called `download_range`, which redid that resolution per block.
|
||||
let reader = self.revision_reader(uid, mtime, fsize).await?;
|
||||
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for &span in &misses {
|
||||
let (core, reader, uid) = (self.clone(), reader.clone(), uid.clone());
|
||||
set.spawn(async move {
|
||||
core.fetch_block(&reader, &uid, mtime, fsize, span, cache_blocks)
|
||||
.await
|
||||
.map(|bytes| (span.idx, bytes))
|
||||
});
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for &span in &misses {
|
||||
let (core, reader, uid) = (self.clone(), reader.clone(), uid.clone());
|
||||
set.spawn(async move {
|
||||
core.fetch_block(&reader, &uid, mtime, fsize, span, cache_blocks)
|
||||
.await
|
||||
.map(|bytes| (span.idx, bytes))
|
||||
});
|
||||
}
|
||||
let mut out = Vec::with_capacity(misses.len());
|
||||
while let Some(joined) = set.join_next().await {
|
||||
// A join error means the task panicked; surface it as EIO.
|
||||
out.push(joined.map_err(|_| Errno::EIO)??);
|
||||
}
|
||||
Ok::<_, Errno>(out)
|
||||
};
|
||||
// Dropping the fetch on the deadline aborts the block tasks with
|
||||
// it; blocks that already landed stay cached for the retry. The
|
||||
// timer is built inside the runtime: a FUSE worker has no reactor
|
||||
// of its own, and building it out here panicked every remote read.
|
||||
let budget = deadline.saturating_duration_since(Instant::now());
|
||||
match self
|
||||
.rt
|
||||
.block_on(async { tokio::time::timeout(budget, fetch).await })
|
||||
{
|
||||
Ok(Ok(fetched)) => break fetched,
|
||||
// The fetch saw the link go down; the top of the loop waits
|
||||
// for it within what is left of the deadline.
|
||||
Ok(Err(_)) if !retried && !self.is_online() => retried = true,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => {
|
||||
warn!(
|
||||
%uid, offset, len, secs = READ_FETCH_TIMEOUT.as_secs(),
|
||||
"read got no answer from the network in time; failing it"
|
||||
);
|
||||
self.evict_reader(uid);
|
||||
self.mark_offline("read", &crate::link::no_answer(READ_FETCH_TIMEOUT));
|
||||
return Err(Errno::EIO);
|
||||
}
|
||||
}
|
||||
let mut out = Vec::with_capacity(misses.len());
|
||||
while let Some(joined) = set.join_next().await {
|
||||
// A join error means the task panicked; surface it as EIO.
|
||||
out.push(joined.map_err(|_| Errno::EIO)??);
|
||||
}
|
||||
Ok::<_, Errno>(out)
|
||||
})?;
|
||||
};
|
||||
for (bidx, bytes) in fetched {
|
||||
blocks[(bidx - first) as usize] = Some(bytes);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
//! Moving a file or folder between two locations without moving its content.
|
||||
//!
|
||||
//! `~/ProtonDrive` and every on-demand synced folder are separate FUSE
|
||||
//! sessions, and a mirror folder is a plain local directory. The kernel refuses
|
||||
//! `rename(2)` between two mounts with `EXDEV` before the daemon hears of it, so
|
||||
//! `mv` falls back to copy and delete: every file is downloaded, uploaded again,
|
||||
//! and the original is trashed.
|
||||
//!
|
||||
//! On Proton Drive the same move is one metadata change, because My files and
|
||||
//! this computer's device folders all live on the main volume. So a move
|
||||
//! request that names two absolute paths is resolved here, against every
|
||||
//! location this daemon knows, and done as a server-side move. The local side
|
||||
//! is then brought level: a mount forgets the node and re-lists, and a mirror
|
||||
//! folder either renames its copy along or drops it once Drive has it.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use pdfs_core::db::{StoredSyncEntry, StoredSyncFolder};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::NodeKind;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::background::forget_and_notify;
|
||||
use super::sync::mirror_subtree_unsynced;
|
||||
use super::{Core, is_local_uid, parse_uid};
|
||||
|
||||
/// How long a move waits for a mirror folder's sync pass to finish before it
|
||||
/// reports the folder busy. A pass can run for hours on a first sync, so the
|
||||
/// move does not wait it out.
|
||||
const MIRROR_LOCK_WAIT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Where one side of a move lives.
|
||||
enum Place {
|
||||
/// My files or an on-demand folder: a live inode space. `core` is rooted at
|
||||
/// that mount and `rel` is relative to its mountpoint.
|
||||
Mounted { core: Box<Core>, rel: PathBuf },
|
||||
/// A mirror folder: a local directory kept in step by the sync engine.
|
||||
/// `rel` is `/`-separated, relative to the folder, as the baseline keys it.
|
||||
Mirror {
|
||||
folder: StoredSyncFolder,
|
||||
rel: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Place {
|
||||
fn mirror_id(&self) -> Option<i64> {
|
||||
match self {
|
||||
Place::Mirror { folder, .. } => Some(folder.id),
|
||||
Place::Mounted { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_root(&self) -> bool {
|
||||
match self {
|
||||
Place::Mounted { rel, .. } => rel.as_os_str().is_empty(),
|
||||
Place::Mirror { rel, .. } => rel.is_empty(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mirror folder that holds `abs`, and `abs` relative to it. The deepest
|
||||
/// folder wins, although folders are not expected to nest.
|
||||
fn mirror_covering(
|
||||
folders: Vec<StoredSyncFolder>,
|
||||
abs: &Path,
|
||||
) -> Option<(StoredSyncFolder, String)> {
|
||||
let folder = folders
|
||||
.into_iter()
|
||||
.filter(|f| f.mode == "mirror" && abs.starts_with(&f.local_path))
|
||||
.max_by_key(|f| Path::new(&f.local_path).components().count())?;
|
||||
let rel = abs.strip_prefix(&folder.local_path).ok()?;
|
||||
let parts = rel
|
||||
.components()
|
||||
.map(|c| match c {
|
||||
Component::Normal(part) => part.to_str(),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
Some((folder, parts.join("/")))
|
||||
}
|
||||
|
||||
/// `name` inside the folder at `parent`, in baseline form.
|
||||
fn join_rel(parent: &str, name: &str) -> String {
|
||||
if parent.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{parent}/{name}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename `from` to `to`, failing with `EEXIST` rather than replacing
|
||||
/// whatever appeared at `to` since it was checked. A filesystem that cannot
|
||||
/// promise that gets a plain rename after one more look.
|
||||
fn rename_noreplace(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let cstr = |p: &Path| {
|
||||
CString::new(p.as_os_str().as_bytes())
|
||||
.map_err(|_| std::io::Error::from_raw_os_error(libc::EINVAL))
|
||||
};
|
||||
let (c_from, c_to) = (cstr(from)?, cstr(to)?);
|
||||
// SAFETY: both are valid NUL-terminated paths that outlive the call.
|
||||
let rc = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_renameat2,
|
||||
libc::AT_FDCWD,
|
||||
c_from.as_ptr(),
|
||||
libc::AT_FDCWD,
|
||||
c_to.as_ptr(),
|
||||
libc::RENAME_NOREPLACE,
|
||||
)
|
||||
};
|
||||
if rc == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let err = std::io::Error::last_os_error();
|
||||
match err.raw_os_error() {
|
||||
Some(libc::EINVAL | libc::ENOSYS) => {
|
||||
if std::fs::symlink_metadata(to).is_ok() {
|
||||
return Err(std::io::Error::from_raw_os_error(libc::EEXIST));
|
||||
}
|
||||
std::fs::rename(from, to)
|
||||
}
|
||||
_ => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `uid` is `root` or lies under it, as far as the nodes table knows.
|
||||
fn under(core: &Core, root: &NodeUid, uid: &str) -> bool {
|
||||
let root = root.to_string();
|
||||
uid == root || matches!(core.db.path_relative_to(&root, uid), Ok(Some(_)))
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Move the file or folder at `src` into the folder at `dest_parent`. Both
|
||||
/// are absolute local paths and may be in different locations: My files,
|
||||
/// an on-demand folder, or a mirror folder. Returns the moved name.
|
||||
///
|
||||
/// Within one inode space this is [`Core::move_to`]. Across locations it
|
||||
/// is a server-side move followed by local bookkeeping; see the module
|
||||
/// documentation. Anything the move could lose is refused up front.
|
||||
pub(crate) fn move_between(&self, src: &Path, dest_parent: &Path) -> CoreResult<String> {
|
||||
let source = self.locate(src)?;
|
||||
let dest = self.locate(dest_parent)?;
|
||||
if let (
|
||||
Place::Mounted { core, rel },
|
||||
Place::Mounted {
|
||||
core: dest_core,
|
||||
rel: dest_rel,
|
||||
},
|
||||
) = (&source, &dest)
|
||||
&& Arc::ptr_eq(&core.state, &dest_core.state)
|
||||
{
|
||||
return core.move_to(rel, dest_rel);
|
||||
}
|
||||
if source.is_root() {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{} is the root of a synced location and cannot be moved",
|
||||
src.display()
|
||||
)));
|
||||
}
|
||||
if dest_parent.starts_with(src) {
|
||||
return Err(CoreError::invalid("cannot move a folder into itself"));
|
||||
}
|
||||
let name = src
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| CoreError::invalid(format!("{} has no usable name", src.display())))?
|
||||
.to_string();
|
||||
|
||||
// Hold every mirror folder involved, lowest id first, so no sync pass
|
||||
// sees the move half done and two moves cannot deadlock.
|
||||
let mut ids: Vec<i64> = [source.mirror_id(), dest.mirror_id()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
let locks: Vec<Arc<Mutex<()>>> = ids.iter().map(|&id| self.sync_lock(id)).collect();
|
||||
let mut guards = Vec::with_capacity(locks.len());
|
||||
for lock in &locks {
|
||||
guards.push(lock.try_lock_for(MIRROR_LOCK_WAIT).ok_or_else(|| {
|
||||
CoreError::conflict("a synced folder is busy syncing; try again when it is done")
|
||||
})?);
|
||||
}
|
||||
|
||||
let mut baselines: HashMap<i64, HashMap<String, StoredSyncEntry>> = HashMap::new();
|
||||
for &id in &ids {
|
||||
let baseline = self
|
||||
.db
|
||||
.sync_entries(id)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e}")))?;
|
||||
baselines.insert(id, baseline);
|
||||
}
|
||||
|
||||
let src_uid = self.source_uid(&source, &baselines)?;
|
||||
let dest_uid = self.dest_uid(&dest, &baselines, &name)?;
|
||||
if src_uid.volume_id != dest_uid.volume_id {
|
||||
return Err(CoreError::invalid(
|
||||
"the two locations are on different Proton Drive volumes; copy it instead",
|
||||
));
|
||||
}
|
||||
if under(self, &src_uid, &dest_uid.to_string()) {
|
||||
return Err(CoreError::invalid("cannot move a folder into itself"));
|
||||
}
|
||||
|
||||
// Mirror to mirror renames the local copy along, so it needs nothing
|
||||
// proven about it. Another filesystem makes that impossible, and the
|
||||
// pair then drops the source copy like any other move out of a mirror.
|
||||
let mut renamed = None;
|
||||
if let (
|
||||
Place::Mirror { folder, rel },
|
||||
Place::Mirror {
|
||||
folder: dest_folder,
|
||||
rel: dest_rel,
|
||||
},
|
||||
) = (&source, &dest)
|
||||
{
|
||||
let from = src.to_path_buf();
|
||||
let to = dest_parent.join(&name);
|
||||
match rename_noreplace(&from, &to) {
|
||||
Ok(()) => renamed = Some((from, to)),
|
||||
Err(e) if e.raw_os_error() == Some(libc::EXDEV) => {
|
||||
info!(from = %folder.local_path, to = %dest_folder.local_path,
|
||||
"move: mirror folders are on different filesystems");
|
||||
}
|
||||
Err(e) if e.raw_os_error() == Some(libc::EEXIST) => {
|
||||
return Err(CoreError::conflict(format!(
|
||||
"{name} already exists in {}",
|
||||
dest_parent.display()
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(CoreError::internal(format!(
|
||||
"could not move {} to {}: {e}",
|
||||
rel,
|
||||
join_rel(dest_rel, &name)
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if renamed.is_none()
|
||||
&& let Place::Mirror { folder, rel } = &source
|
||||
&& let Some(reason) =
|
||||
mirror_subtree_unsynced(Path::new(&folder.local_path), rel, &baselines[&folder.id])
|
||||
{
|
||||
return Err(CoreError::conflict(format!(
|
||||
"{name} is not fully synced yet ({reason}); wait for the sync to finish"
|
||||
)));
|
||||
}
|
||||
|
||||
if let Err(e) = self.rt.block_on(self.client.move_node(&src_uid, &dest_uid)) {
|
||||
if let Some((from, to)) = &renamed
|
||||
&& let Err(back) = std::fs::rename(to, from)
|
||||
{
|
||||
warn!(from = %to.display(), to = %from.display(), error = %back,
|
||||
"move: could not put the local copy back after the move failed");
|
||||
}
|
||||
return Err(CoreError::from_api(&e, "move"));
|
||||
}
|
||||
|
||||
self.settle_source(&source, &src_uid, renamed.is_some(), &dest, &name, src);
|
||||
self.settle_dest(&dest, &dest_uid, &name);
|
||||
drop(guards);
|
||||
if renamed.is_none()
|
||||
&& let Place::Mirror { folder, .. } = &dest
|
||||
{
|
||||
// Nothing local exists yet; the folder's next pass downloads it.
|
||||
self.sync_now(Some(folder.id));
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Which location `abs` is in. A mount is looked for first: a mirror folder
|
||||
/// cannot live inside a FUSE mount, and a mount is what the kernel sees.
|
||||
fn locate(&self, abs: &Path) -> CoreResult<Place> {
|
||||
if let Some((core, rel)) = self.rooted_at(abs) {
|
||||
if !rel
|
||||
.components()
|
||||
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
|
||||
{
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{} escapes its location",
|
||||
abs.display()
|
||||
)));
|
||||
}
|
||||
return Ok(Place::Mounted {
|
||||
core: Box::new(core),
|
||||
rel,
|
||||
});
|
||||
}
|
||||
let folders = self
|
||||
.db
|
||||
.sync_folder_list()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e}")))?;
|
||||
mirror_covering(folders, abs)
|
||||
.map(|(folder, rel)| Place::Mirror { folder, rel })
|
||||
.ok_or_else(|| {
|
||||
CoreError::invalid(format!(
|
||||
"{} is not in a Proton Drive location",
|
||||
abs.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// The node being moved, once everything that must hold before it leaves
|
||||
/// its location has been checked.
|
||||
fn source_uid(
|
||||
&self,
|
||||
source: &Place,
|
||||
baselines: &HashMap<i64, HashMap<String, StoredSyncEntry>>,
|
||||
) -> CoreResult<NodeUid> {
|
||||
match source {
|
||||
Place::Mounted { core, rel } => {
|
||||
let (ino, uid) = core.resolve(rel)?;
|
||||
if is_local_uid(&uid) {
|
||||
return Err(CoreError::conflict(
|
||||
"it is not uploaded yet; wait for the upload to finish",
|
||||
));
|
||||
}
|
||||
let old_parent = core.source_parent_uid(ino, rel)?;
|
||||
for authority in [&uid, &old_parent] {
|
||||
core.require_uid_writable(authority)
|
||||
.map_err(|e| core.errno_error(e, "move access"))?;
|
||||
}
|
||||
if self.has_pending_work_under(&uid)? {
|
||||
return Err(CoreError::conflict(
|
||||
"it has changes that are not uploaded yet; wait for the upload to finish",
|
||||
));
|
||||
}
|
||||
Ok(uid)
|
||||
}
|
||||
Place::Mirror { folder, rel } => baselines[&folder.id]
|
||||
.get(rel)
|
||||
.and_then(|entry| entry.remote_uid.as_deref())
|
||||
.and_then(parse_uid)
|
||||
.ok_or_else(|| {
|
||||
CoreError::conflict(
|
||||
"it is not on Proton Drive yet; wait for the sync to finish",
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder the node moves into. Refuses one that already holds `name`
|
||||
/// on this disk; Drive refuses a clash on its side by itself.
|
||||
fn dest_uid(
|
||||
&self,
|
||||
dest: &Place,
|
||||
baselines: &HashMap<i64, HashMap<String, StoredSyncEntry>>,
|
||||
name: &str,
|
||||
) -> CoreResult<NodeUid> {
|
||||
let uid = match dest {
|
||||
Place::Mounted { core, rel } => {
|
||||
let (ino, uid) = core.resolve(rel)?;
|
||||
let is_folder = core
|
||||
.state()
|
||||
.entries
|
||||
.get(&ino)
|
||||
.is_some_and(|entry| matches!(entry.node.kind, NodeKind::Folder));
|
||||
if !is_folder {
|
||||
return Err(CoreError::invalid("the destination is not a folder"));
|
||||
}
|
||||
core.require_uid_writable(&uid)
|
||||
.map_err(|e| core.errno_error(e, "move access"))?;
|
||||
uid
|
||||
}
|
||||
Place::Mirror { folder, rel } => {
|
||||
let local = Path::new(&folder.local_path).join(rel);
|
||||
if !local.is_dir() {
|
||||
return Err(CoreError::invalid("the destination is not a folder"));
|
||||
}
|
||||
if std::fs::symlink_metadata(local.join(name)).is_ok() {
|
||||
return Err(CoreError::conflict(format!(
|
||||
"{name} already exists in the destination"
|
||||
)));
|
||||
}
|
||||
let uid = if rel.is_empty() {
|
||||
Some(folder.remote_uid.as_str())
|
||||
} else {
|
||||
baselines[&folder.id]
|
||||
.get(rel)
|
||||
.and_then(|entry| entry.remote_uid.as_deref())
|
||||
};
|
||||
uid.and_then(parse_uid).ok_or_else(|| {
|
||||
CoreError::conflict(
|
||||
"the destination is not on Proton Drive yet; wait for the sync to finish",
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
if is_local_uid(&uid) {
|
||||
return Err(CoreError::conflict(
|
||||
"the destination is not uploaded yet; wait for the upload to finish",
|
||||
));
|
||||
}
|
||||
Ok(uid)
|
||||
}
|
||||
|
||||
/// Whether the queue owes Drive anything for `uid` or a node under it, or a
|
||||
/// file under it is open for writing. Moving such a node away would strand
|
||||
/// the pending change against a path that no longer leads to it.
|
||||
fn has_pending_work_under(&self, uid: &NodeUid) -> CoreResult<bool> {
|
||||
let ops = self
|
||||
.db
|
||||
.pending_ops()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e}")))?;
|
||||
if ops.iter().any(|op| {
|
||||
under(self, uid, &op.uid)
|
||||
|| op
|
||||
.parent_uid
|
||||
.as_deref()
|
||||
.is_some_and(|p| under(self, uid, p))
|
||||
}) {
|
||||
return Ok(true);
|
||||
}
|
||||
// Collected first so no State lock is held across the nodes-table reads.
|
||||
let mut writing = Vec::new();
|
||||
self.for_each_state(|st| {
|
||||
writing.extend(st.active_writes.values().map(|w| w.uid.to_string()));
|
||||
});
|
||||
Ok(writing.iter().any(|w| under(self, uid, w)))
|
||||
}
|
||||
|
||||
/// Bring the source location level with a move Drive has made.
|
||||
fn settle_source(
|
||||
&self,
|
||||
source: &Place,
|
||||
uid: &NodeUid,
|
||||
renamed: bool,
|
||||
dest: &Place,
|
||||
name: &str,
|
||||
src: &Path,
|
||||
) {
|
||||
match source {
|
||||
Place::Mounted { core, rel } => {
|
||||
// Forget it everywhere, so no mount keeps serving it under the
|
||||
// old path. The content cache is keyed by uid and stays valid.
|
||||
self.for_each_mount(|st, notify| {
|
||||
forget_and_notify(st, notify, uid);
|
||||
});
|
||||
core.invalidate_parent_listing(rel);
|
||||
}
|
||||
Place::Mirror { folder, rel } if renamed => {
|
||||
let Place::Mirror {
|
||||
folder: dest_folder,
|
||||
rel: dest_rel,
|
||||
} = dest
|
||||
else {
|
||||
unreachable!("a local rename needs a mirror destination");
|
||||
};
|
||||
if let Err(e) = self.db.sync_entries_move(
|
||||
folder.id,
|
||||
rel,
|
||||
dest_folder.id,
|
||||
&join_rel(dest_rel, name),
|
||||
) {
|
||||
// The next passes see a copy with no baseline on one side
|
||||
// and a baseline with no copy on the other. That uploads
|
||||
// the files again and trashes the moved node, but loses
|
||||
// nothing.
|
||||
warn!(error = ?e, "move: could not carry the sync baseline along");
|
||||
}
|
||||
}
|
||||
Place::Mirror { folder, rel } => {
|
||||
// Drive had all of it when the move was checked, so the local
|
||||
// copy is only a stale duplicate now. Something written into it
|
||||
// since would go with it, so look again first; if anything is
|
||||
// new, the copy stays and the next pass uploads it as new.
|
||||
let changed = match self.db.sync_entries(folder.id) {
|
||||
Ok(baseline) => {
|
||||
mirror_subtree_unsynced(Path::new(&folder.local_path), rel, &baseline)
|
||||
}
|
||||
Err(e) => Some(format!("db: {e}")),
|
||||
};
|
||||
if let Err(e) = self.db.sync_entries_remove_subtree(folder.id, rel) {
|
||||
warn!(error = ?e, "move: could not drop the sync baseline");
|
||||
}
|
||||
if let Some(reason) = changed {
|
||||
warn!(path = %src.display(), reason,
|
||||
"move: the local copy changed during the move; keeping it");
|
||||
return;
|
||||
}
|
||||
let removed = if src.is_dir() {
|
||||
std::fs::remove_dir_all(src)
|
||||
} else {
|
||||
std::fs::remove_file(src)
|
||||
};
|
||||
if let Err(e) = removed {
|
||||
warn!(path = %src.display(), error = %e,
|
||||
"move: could not remove the local copy after the move");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring the destination location level with a move Drive has made.
|
||||
fn settle_dest(&self, dest: &Place, uid: &NodeUid, name: &str) {
|
||||
if let Place::Mounted { .. } = dest {
|
||||
self.invalidate_children_of(uid);
|
||||
// A lookup that missed before the move may be cached as absent.
|
||||
self.for_each_mount(|st, notify| {
|
||||
if let Some(&ino) = st.by_uid.get(uid) {
|
||||
notify.inval_entry(ino, name.to_string());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn folder(id: i64, local_path: &str, mode: &str) -> StoredSyncFolder {
|
||||
StoredSyncFolder {
|
||||
id,
|
||||
local_path: local_path.to_string(),
|
||||
remote_uid: format!("vol~root{id}"),
|
||||
remote_share_id: String::new(),
|
||||
mode: mode.to_string(),
|
||||
pending_mode: None,
|
||||
state: "idle".to_string(),
|
||||
last_sync: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_covering_finds_the_mirror_folder_and_the_path_inside_it() {
|
||||
let folders = vec![folder(1, "/home/u/Docs", "mirror")];
|
||||
let (found, rel) = mirror_covering(folders, Path::new("/home/u/Docs/a/b.txt")).unwrap();
|
||||
assert_eq!(found.id, 1);
|
||||
assert_eq!(rel, "a/b.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_covering_gives_an_empty_path_for_the_folder_itself() {
|
||||
let folders = vec![folder(1, "/home/u/Docs", "mirror")];
|
||||
let (_, rel) = mirror_covering(folders, Path::new("/home/u/Docs")).unwrap();
|
||||
assert_eq!(rel, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_covering_prefers_the_deepest_folder() {
|
||||
let folders = vec![
|
||||
folder(1, "/home/u/Docs", "mirror"),
|
||||
folder(2, "/home/u/Docs/Work", "mirror"),
|
||||
];
|
||||
let (found, rel) = mirror_covering(folders, Path::new("/home/u/Docs/Work/x")).unwrap();
|
||||
assert_eq!(found.id, 2);
|
||||
assert_eq!(rel, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_covering_skips_on_demand_folders_and_name_prefixed_siblings() {
|
||||
let folders = || {
|
||||
vec![
|
||||
folder(1, "/home/u/Docs", "mirror"),
|
||||
folder(2, "/home/u/Media", "ondemand"),
|
||||
]
|
||||
};
|
||||
assert!(mirror_covering(folders(), Path::new("/home/u/Media/x")).is_none());
|
||||
assert!(mirror_covering(folders(), Path::new("/home/u/Docs2/x")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_covering_refuses_a_path_that_climbs_out() {
|
||||
let folders = vec![folder(1, "/home/u/Docs", "mirror")];
|
||||
assert!(mirror_covering(folders, Path::new("/home/u/Docs/../etc")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_rel_names_a_child_of_the_folder_root_without_a_slash() {
|
||||
assert_eq!(join_rel("", "a"), "a");
|
||||
assert_eq!(join_rel("x/y", "a"), "x/y/a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_rename_never_replaces_what_is_already_there() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfs-relocate-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (a, b, c) = (dir.join("a"), dir.join("b"), dir.join("c"));
|
||||
std::fs::write(&a, b"moved").unwrap();
|
||||
std::fs::write(&b, b"kept").unwrap();
|
||||
|
||||
let err = rename_noreplace(&a, &b).unwrap_err();
|
||||
assert_eq!(err.raw_os_error(), Some(libc::EEXIST));
|
||||
assert_eq!(std::fs::read(&b).unwrap(), b"kept");
|
||||
assert!(a.exists());
|
||||
|
||||
rename_noreplace(&a, &c).unwrap();
|
||||
assert!(!a.exists());
|
||||
assert_eq!(std::fs::read(&c).unwrap(), b"moved");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,8 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
||||
shared_by,
|
||||
shared_at,
|
||||
shared_by_unverified,
|
||||
trashed_at: 0,
|
||||
trashed_from: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,10 +610,10 @@ impl Core {
|
||||
/// Proton products (not Drive-only). A remote round-trip; nothing here is
|
||||
/// cached.
|
||||
pub(crate) fn account_quota(&self) -> CoreResult<(i64, i64)> {
|
||||
let q = self
|
||||
.rt
|
||||
.block_on(self.client.quota())
|
||||
.map_err(|e| CoreError::from_api(&e, "account quota"))?;
|
||||
let q = self.block_on_bounded(self.client.quota()).map_err(|e| {
|
||||
self.lost_link(&e, "account quota");
|
||||
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))
|
||||
}
|
||||
@@ -630,11 +632,22 @@ impl Core {
|
||||
{
|
||||
return Some((max_space, used_space));
|
||||
}
|
||||
match self.account_quota() {
|
||||
let last = || self.quota.lock().map(|(_, max, used)| (max, used));
|
||||
if !self.is_online()
|
||||
|| self
|
||||
.quota_refreshing
|
||||
.swap(true, std::sync::atomic::Ordering::AcqRel)
|
||||
{
|
||||
return last();
|
||||
}
|
||||
let refreshed = self.account_quota();
|
||||
self.quota_refreshing
|
||||
.store(false, std::sync::atomic::Ordering::Release);
|
||||
match refreshed {
|
||||
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))
|
||||
last()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,23 @@ pub(crate) struct WriteHandle {
|
||||
pub(crate) open_count: usize,
|
||||
}
|
||||
|
||||
impl WriteHandle {
|
||||
/// Account for `[offset, end)` having been written through this handle.
|
||||
///
|
||||
/// A write past the end leaves a hole that reads as zeros, exactly like a
|
||||
/// grow through `setattr`, so the hole is authored too. Left unauthored, a
|
||||
/// hole below the base size was refilled from the base at commit: a file
|
||||
/// shrunk and then written past its new end got its old bytes back.
|
||||
pub(crate) fn record_write(&mut self, offset: u64, end: u64) {
|
||||
if offset > self.len {
|
||||
self.written.add(self.len, offset);
|
||||
}
|
||||
self.written.add(offset, end);
|
||||
self.len = self.len.max(end);
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// A released write whose upload has not happened yet (offline.md Phase 3).
|
||||
///
|
||||
/// The bytes live in the content cache's staging dir and the intent lives in the
|
||||
@@ -783,12 +800,13 @@ impl State {
|
||||
self.intern_mem_with_access(parent, node, access)
|
||||
}
|
||||
|
||||
fn intern_mem_with_access(&mut self, parent: u64, node: Node, access: Access) -> u64 {
|
||||
fn intern_mem_with_access(&mut self, parent: u64, mut node: Node, access: Access) -> u64 {
|
||||
if let Some(&ino) = self.by_uid.get(&node.uid) {
|
||||
let changed = self
|
||||
.entries
|
||||
.get(&ino)
|
||||
.is_some_and(|entry| entry.access != access);
|
||||
self.keep_open_write_size(ino, &mut node);
|
||||
if let Some(e) = self.entries.get_mut(&ino) {
|
||||
e.node = node;
|
||||
e.parent = parent;
|
||||
@@ -833,6 +851,23 @@ impl State {
|
||||
|
||||
/// Allocate (or reuse) a stable inode for a node that came *from* the
|
||||
/// database, which is why nothing is written back.
|
||||
/// Carry the size and mtime of a file open for writing over onto `node`,
|
||||
/// which is about to replace its entry.
|
||||
///
|
||||
/// The open handle is ahead of anything Drive can report until release
|
||||
/// queues it. Taking the remote's size shrank a SQLite database to its empty
|
||||
/// create while SQLite held it open, and the kernel then capped every read
|
||||
/// at that size (docs/BUGS.md B109).
|
||||
pub(crate) fn keep_open_write_size(&self, ino: u64, node: &mut Node) {
|
||||
let (Some(aw), Some(entry)) = (self.active_writes.get(&ino), self.entries.get(&ino)) else {
|
||||
return;
|
||||
};
|
||||
if let NodeKind::File { claimed_size, .. } = &mut node.kind {
|
||||
*claimed_size = Some(aw.len as i64);
|
||||
node.modification_time = entry.node.modification_time;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn intern_from_db(&mut self, parent: u64, node: Node) -> u64 {
|
||||
self.intern_mem(parent, node)
|
||||
}
|
||||
@@ -1863,4 +1898,71 @@ mod tests {
|
||||
let shared = st.intern(owned_root, shared);
|
||||
assert_eq!(st.entries[&shared].access, Access::Viewer);
|
||||
}
|
||||
|
||||
fn handle(len: u64, base_size: u64) -> WriteHandle {
|
||||
let path = std::env::temp_dir().join(format!("pdfs-handle-test-{}", std::process::id()));
|
||||
let file = File::create(&path).unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
WriteHandle {
|
||||
ino: 2,
|
||||
uid: uid("file"),
|
||||
file: Arc::new(file),
|
||||
path,
|
||||
written: Intervals::default(),
|
||||
len,
|
||||
base_size,
|
||||
base_mtime: 0,
|
||||
base_revision_id: None,
|
||||
dirty: false,
|
||||
open_count: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_listing_does_not_shrink_a_file_open_for_writing() {
|
||||
let (mut st, _dir) = state();
|
||||
let ino = st.intern(0, node("file", "root", "workload.db", false));
|
||||
let mut h = handle(16384, 0);
|
||||
h.ino = ino;
|
||||
st.active_writes.insert(ino, h);
|
||||
st.set_size(ino, 16384);
|
||||
st.touch_mtime(ino, 500);
|
||||
|
||||
// The remote still holds the empty file the create sealed.
|
||||
st.intern(0, node("file", "root", "workload.db", false));
|
||||
assert_eq!(crate::node_size(&st.entries[&ino].node), 16384);
|
||||
assert_eq!(st.entries[&ino].node.modification_time, 500);
|
||||
|
||||
// Once the handle is gone the remote is authoritative again.
|
||||
st.active_writes.remove(&ino);
|
||||
st.intern(0, node("file", "root", "workload.db", false));
|
||||
assert_eq!(crate::node_size(&st.entries[&ino].node), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_past_the_end_authors_the_hole_it_leaves() {
|
||||
// Opened over a 100-byte base, shrunk to 10, then written at 50: bytes
|
||||
// 10..50 are zeros now, not the base's bytes 10..50.
|
||||
let mut h = handle(100, 100);
|
||||
h.written.clip(10);
|
||||
h.len = 10;
|
||||
h.record_write(50, 60);
|
||||
assert_eq!(h.len, 60);
|
||||
assert_eq!(
|
||||
h.written.segments(0, 60),
|
||||
vec![(0, 10, false), (10, 60, true)],
|
||||
"only the head still comes from the base"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_inside_the_file_leaves_the_rest_to_the_base() {
|
||||
let mut h = handle(100, 100);
|
||||
h.record_write(40, 50);
|
||||
assert_eq!(h.len, 100);
|
||||
assert_eq!(
|
||||
h.written.segments(0, 100),
|
||||
vec![(0, 40, false), (40, 50, true), (50, 100, false)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,9 @@ enum Applied {
|
||||
Downloaded,
|
||||
/// A conflict copy was kept.
|
||||
Conflict,
|
||||
/// The local file to upload was deleted after the scan planned it. There is
|
||||
/// nothing left to send, and the next pass sees the deletion (B108).
|
||||
Vanished,
|
||||
}
|
||||
|
||||
impl Core {
|
||||
@@ -1147,13 +1150,10 @@ impl Core {
|
||||
Ok(Applied::Dir(rel.clone(), uid))
|
||||
}
|
||||
Pending::UploadNew { rel, parent } => {
|
||||
self.upload_new(folder_id, local_root, rel, parent).await?;
|
||||
Ok(Applied::Uploaded)
|
||||
self.upload_new(folder_id, local_root, rel, parent).await
|
||||
}
|
||||
Pending::UploadRevision { rel, uid } => {
|
||||
self.upload_revision(folder_id, local_root, rel, uid)
|
||||
.await?;
|
||||
Ok(Applied::Uploaded)
|
||||
self.upload_revision(folder_id, local_root, rel, uid).await
|
||||
}
|
||||
Pending::Download {
|
||||
rel,
|
||||
@@ -1217,10 +1217,12 @@ impl Core {
|
||||
local_root: &Path,
|
||||
rel: &str,
|
||||
parent: &NodeUid,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<Applied, String> {
|
||||
let path = local_root.join(rel_to_path(rel));
|
||||
let name = base_name(rel);
|
||||
let file = std::fs::File::open(&path).map_err(|e| format!("open {rel}: {e}"))?;
|
||||
let Some(file) = open_to_upload(&path, rel)? else {
|
||||
return Ok(Applied::Vanished);
|
||||
};
|
||||
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
|
||||
let mtime = system_mtime(&meta);
|
||||
// Taken from the descriptor being streamed, so it names the content that
|
||||
@@ -1261,7 +1263,8 @@ impl Core {
|
||||
self.invalidate_children_of(parent);
|
||||
warn_if_torn(rel, &path, streamed);
|
||||
self.record_file_baseline(folder_id, rel, Some(streamed), &uid)
|
||||
.await
|
||||
.await?;
|
||||
Ok(Applied::Uploaded)
|
||||
}
|
||||
|
||||
/// Upload a changed local file as a new revision of an existing remote node.
|
||||
@@ -1271,9 +1274,11 @@ impl Core {
|
||||
local_root: &Path,
|
||||
rel: &str,
|
||||
uid: &NodeUid,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<Applied, String> {
|
||||
let path = local_root.join(rel_to_path(rel));
|
||||
let file = std::fs::File::open(&path).map_err(|e| format!("open {rel}: {e}"))?;
|
||||
let Some(file) = open_to_upload(&path, rel)? else {
|
||||
return Ok(Applied::Vanished);
|
||||
};
|
||||
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
|
||||
let mtime = system_mtime(&meta);
|
||||
let streamed = LocalSig::from(&meta);
|
||||
@@ -1295,7 +1300,8 @@ impl Core {
|
||||
.map_err(|e| format!("upload revision {rel}: {e}"))?;
|
||||
warn_if_torn(rel, &path, streamed);
|
||||
self.record_file_baseline(folder_id, rel, Some(streamed), uid)
|
||||
.await
|
||||
.await?;
|
||||
Ok(Applied::Uploaded)
|
||||
}
|
||||
|
||||
/// Download a remote file to its local path (atomically via a temp file),
|
||||
@@ -1328,7 +1334,21 @@ impl Core {
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
{
|
||||
// A file an on-demand mount already read, say one just moved here from
|
||||
// it, is in the content cache under the same revision key.
|
||||
let seeded = size > 0 && self.cache.copy_cached_to(uid, mtime, size as u64, &tmp);
|
||||
if seeded {
|
||||
std::fs::File::open(&tmp)
|
||||
.and_then(|f| f.sync_all())
|
||||
.map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
format!("sync tmp {rel}: {e}")
|
||||
})?;
|
||||
debug!(
|
||||
rel,
|
||||
"sync: placed from the content cache instead of downloading"
|
||||
);
|
||||
} else {
|
||||
let out = std::fs::File::create(&tmp).map_err(|e| format!("create tmp {rel}: {e}"))?;
|
||||
// Count the bytes as they land, so the download reports progress like
|
||||
// an on-demand hydration does.
|
||||
@@ -1431,6 +1451,23 @@ impl Core {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a local file for upload, or `None` when it is gone.
|
||||
///
|
||||
/// A scan plans uploads from a listing, and the file can be deleted before its
|
||||
/// upload starts — a build or a test tree cleaned up mid-pass. That is not a
|
||||
/// failure: the next pass sees the deletion. Counting it as one put the folder
|
||||
/// in `error` for a file nobody wanted any more (B108).
|
||||
fn open_to_upload(path: &Path, rel: &str) -> Result<Option<std::fs::File>, String> {
|
||||
match std::fs::File::open(path) {
|
||||
Ok(file) => Ok(Some(file)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
debug!(rel, "sync: file deleted before its upload; skipping");
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => Err(format!("open {rel}: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Say so when a file changed under an upload that was streaming it.
|
||||
///
|
||||
/// Purely diagnostic — the correction is already in place, because the baseline
|
||||
@@ -1602,6 +1639,76 @@ fn walk_local_tree(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Why the mirror copy at `rel` holds something Proton Drive does not, or
|
||||
/// `None` when everything under it is on Drive exactly as it is on disk.
|
||||
///
|
||||
/// A move out of a mirror folder removes the local copy once Drive has moved
|
||||
/// the node, so anything only this disk has would be lost with it: a file not
|
||||
/// uploaded yet, one edited since, a deletion not carried up yet, or anything
|
||||
/// the engine never uploads. That is why ignored names count here, and why the
|
||||
/// walk is its own rather than [`walk_local_tree`], which skips symlinks.
|
||||
pub(crate) fn mirror_subtree_unsynced(
|
||||
root: &Path,
|
||||
rel: &str,
|
||||
baseline: &HashMap<String, StoredSyncEntry>,
|
||||
) -> Option<String> {
|
||||
let mut seen = HashSet::new();
|
||||
if let Some(reason) = unsynced_local(root, rel, baseline, &mut seen) {
|
||||
return Some(reason);
|
||||
}
|
||||
let prefix = format!("{rel}/");
|
||||
baseline
|
||||
.keys()
|
||||
.filter(|r| *r == rel || r.starts_with(&prefix))
|
||||
.find(|r| !seen.contains(r.as_str()))
|
||||
.map(|r| format!("{r} was deleted here but not on Proton Drive yet"))
|
||||
}
|
||||
|
||||
fn unsynced_local(
|
||||
root: &Path,
|
||||
rel: &str,
|
||||
baseline: &HashMap<String, StoredSyncEntry>,
|
||||
seen: &mut HashSet<String>,
|
||||
) -> Option<String> {
|
||||
let path = root.join(rel_to_path(rel));
|
||||
let meta = match std::fs::symlink_metadata(&path) {
|
||||
Ok(meta) => meta,
|
||||
Err(e) => return Some(format!("cannot read {rel}: {e}")),
|
||||
};
|
||||
let Some(base) = baseline.get(rel) else {
|
||||
return Some(format!("{rel} is not on Proton Drive yet"));
|
||||
};
|
||||
seen.insert(rel.to_string());
|
||||
if meta.is_file() {
|
||||
return (!LocalSig::from(&meta).same_content(&LocalSig::from(base)))
|
||||
.then(|| format!("{rel} has changes that are not on Proton Drive yet"));
|
||||
}
|
||||
if !meta.is_dir() {
|
||||
return Some(format!("{rel} is not a regular file or folder"));
|
||||
}
|
||||
let entries = match std::fs::read_dir(&path) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => return Some(format!("cannot read {rel}: {e}")),
|
||||
};
|
||||
for entry in entries {
|
||||
let name = match entry {
|
||||
Ok(entry) => entry.file_name(),
|
||||
Err(e) => return Some(format!("cannot read {rel}: {e}")),
|
||||
};
|
||||
let Some(name) = name.to_str() else {
|
||||
return Some(format!("{rel} holds a name that is not valid UTF-8"));
|
||||
};
|
||||
// A download the engine left half done; it is the engine's to clean up.
|
||||
if name.contains(".pdfs-tmp-") {
|
||||
continue;
|
||||
}
|
||||
if let Some(reason) = unsynced_local(root, &format!("{rel}/{name}"), baseline, seen) {
|
||||
return Some(reason);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Publish a conflict copy without overwriting an earlier conflict bearing the
|
||||
/// 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> {
|
||||
@@ -2155,6 +2262,27 @@ mod tests {
|
||||
assert_eq!(remote_sig(&e), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_deleted_before_its_upload_is_skipped_not_failed() {
|
||||
let root = std::env::temp_dir().join(format!("pdfs-b108-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let gone = root.join("gone.txt");
|
||||
assert!(open_to_upload(&gone, "gone.txt").unwrap().is_none());
|
||||
|
||||
std::fs::write(&gone, b"x").unwrap();
|
||||
assert!(open_to_upload(&gone, "gone.txt").unwrap().is_some());
|
||||
|
||||
// Other open failures still count as errors.
|
||||
let err = open_to_upload(&gone.join("child"), "gone.txt/child");
|
||||
assert!(err.unwrap_err().starts_with("open gone.txt/child:"));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
let mut o = Outcome::default();
|
||||
o.record(&Applied::Vanished);
|
||||
assert!(o.is_empty());
|
||||
assert_eq!(o.errors, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_summarises_only_what_it_moved() {
|
||||
let mut o = Outcome::default();
|
||||
@@ -2346,6 +2474,94 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A baseline row recording `path` as it is on disk right now.
|
||||
fn synced_row(root: &Path, rel: &str) -> (String, StoredSyncEntry) {
|
||||
let meta = std::fs::symlink_metadata(root.join(rel_to_path(rel))).unwrap();
|
||||
let sig = LocalSig::from(&meta);
|
||||
let entry = StoredSyncEntry {
|
||||
rel_path: rel.to_string(),
|
||||
remote_uid: Some(format!("vol~{rel}")),
|
||||
local_mtime: sig.mtime,
|
||||
local_mtime_ns: sig.mtime_ns,
|
||||
local_size: sig.size,
|
||||
remote_rev: None,
|
||||
remote_hash: None,
|
||||
};
|
||||
(rel.to_string(), entry)
|
||||
}
|
||||
|
||||
fn synced_subtree(name: &str) -> (PathBuf, HashMap<String, StoredSyncEntry>) {
|
||||
let root = sync_test_dir(name);
|
||||
std::fs::create_dir_all(root.join("dir/sub")).unwrap();
|
||||
std::fs::write(root.join("dir/a.txt"), b"a").unwrap();
|
||||
std::fs::write(root.join("dir/sub/b.txt"), b"b").unwrap();
|
||||
let baseline = ["dir", "dir/sub", "dir/a.txt", "dir/sub/b.txt"]
|
||||
.into_iter()
|
||||
.map(|rel| synced_row(&root, rel))
|
||||
.collect();
|
||||
(root, baseline)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fully_synced_mirror_subtree_may_leave() {
|
||||
let (root, baseline) = synced_subtree("move-synced");
|
||||
let reason = mirror_subtree_unsynced(&root, "dir", &baseline);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mirror_subtree_with_a_file_not_uploaded_may_not_leave() {
|
||||
let (root, baseline) = synced_subtree("move-new-file");
|
||||
std::fs::write(root.join("dir/sub/new.txt"), b"n").unwrap();
|
||||
let reason = mirror_subtree_unsynced(&root, "dir", &baseline);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert!(reason.unwrap().contains("dir/sub/new.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mirror_subtree_with_an_edit_not_uploaded_may_not_leave() {
|
||||
let (root, baseline) = synced_subtree("move-edit");
|
||||
std::fs::write(root.join("dir/a.txt"), b"longer now").unwrap();
|
||||
let reason = mirror_subtree_unsynced(&root, "dir", &baseline);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert!(reason.unwrap().contains("dir/a.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mirror_subtree_with_a_deletion_not_carried_up_may_not_leave() {
|
||||
let (root, baseline) = synced_subtree("move-deleted");
|
||||
std::fs::remove_file(root.join("dir/sub/b.txt")).unwrap();
|
||||
let reason = mirror_subtree_unsynced(&root, "dir", &baseline);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert!(reason.unwrap().contains("dir/sub/b.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mirror_subtree_holding_a_symlink_may_not_leave() {
|
||||
let (root, baseline) = synced_subtree("move-symlink");
|
||||
std::os::unix::fs::symlink("a.txt", root.join("dir/link")).unwrap();
|
||||
let reason = mirror_subtree_unsynced(&root, "dir", &baseline);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert!(reason.unwrap().contains("dir/link"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_prefixed_sibling_does_not_hold_a_mirror_subtree_back() {
|
||||
let (root, mut baseline) = synced_subtree("move-sibling");
|
||||
// Deleted here but not on Drive, yet outside the subtree being moved.
|
||||
baseline.insert(
|
||||
"dirt".to_string(),
|
||||
StoredSyncEntry {
|
||||
rel_path: "dirt".to_string(),
|
||||
..baseline["dir/a.txt"].clone()
|
||||
},
|
||||
);
|
||||
let reason = mirror_subtree_unsynced(&root, "dir", &baseline);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
@@ -108,6 +108,7 @@ impl Outcome {
|
||||
Applied::Uploaded => self.uploaded += 1,
|
||||
Applied::Downloaded => self.downloaded += 1,
|
||||
Applied::Conflict => self.conflicts += 1,
|
||||
Applied::Vanished => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,5 +27,6 @@ fn main() {
|
||||
println!("cargo:rerun-if-changed=resources/icons");
|
||||
println!("cargo:rerun-if-changed=resources/style.css");
|
||||
println!("cargo:rerun-if-changed=resources/proton-theme.css");
|
||||
println!("cargo:rerun-if-changed=resources/prompt.css");
|
||||
println!("cargo:rerun-if-changed=../../packaging/io.narl.proton-drive-linux.svg");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<!-- A copy is on this computer: a check in an outlined circle. Adwaita 50
|
||||
no longer ships emblem-ok-symbolic. -->
|
||||
<path fill="#222222" fill-rule="evenodd" d="M 8,1 A 7,7 0 1 0 8,15 A 7,7 0 1 0 8,1 Z M 8,2.5 A 5.5,5.5 0 1 1 8,13.5 A 5.5,5.5 0 1 1 8,2.5 Z"/>
|
||||
<path fill="#222222" d="M 4.1,8.6 L 5.2,7.5 L 7,9.3 L 10.8,5.4 L 11.9,6.5 L 7,11.5 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 450 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<!-- Show or hide the details pane: a window with its right-hand pane
|
||||
filled in. Some themes draw sidebar-show-right-symbolic as a solid block. -->
|
||||
<path fill="#222222" fill-rule="evenodd" d="M 3,2 H 13 A 2,2 0 0 1 15,4 V 12 A 2,2 0 0 1 13,14 H 3 A 2,2 0 0 1 1,12 V 4 A 2,2 0 0 1 3,2 Z M 3,3.5 A 0.5,0.5 0 0 0 2.5,4 V 12 A 0.5,0.5 0 0 0 3,12.5 H 13 A 0.5,0.5 0 0 0 13.5,12 V 4 A 0.5,0.5 0 0 0 13,3.5 Z"/>
|
||||
<path fill="#222222" d="M 9.5,5 H 12.5 V 11 H 9.5 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 564 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<!-- A share link: two open chain ends and the bar that joins them, laid
|
||||
on the diagonal. Written out in plain coordinates: GTK's symbolic icon
|
||||
renderer drops a transformed group. -->
|
||||
<path fill="#222222" d="M 4.82,6.23 L 3.05,8 A 3.5,3.5 0 0 0 8,12.95 L 9.77,11.18 L 8.53,9.94 L 6.76,11.71 A 1.75,1.75 0 0 1 4.29,9.24 L 6.06,7.47 Z"/>
|
||||
<path fill="#222222" d="M 6.23,4.82 L 8,3.05 A 3.5,3.5 0 0 1 12.95,8 L 11.18,9.77 L 9.94,8.53 L 11.71,6.76 A 1.75,1.75 0 0 0 9.24,4.29 L 7.47,6.06 Z"/>
|
||||
<path fill="#222222" d="M 5.26,9.5 L 9.5,5.26 L 10.74,6.5 L 6.5,10.74 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 676 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<!-- Show on map: a folded map in three panels. No stock theme has a
|
||||
map-symbolic. -->
|
||||
<path fill="#222222" d="M 1,3.2 L 5,1.5 V 12.8 L 1,14.5 Z M 11,3.2 L 15,1.5 V 12.8 L 11,14.5 Z"/>
|
||||
<path fill="#222222" opacity="0.55" d="M 6,1.5 L 10,3.2 V 14.5 L 6,12.8 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 366 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<!-- Main menu: three bars. Bundled because themes such as Papirus draw
|
||||
open-menu-symbolic as three dots, the same as every row's "more" menu. -->
|
||||
<path fill="#222222" d="M 2,2.5 H 14 A 1,1 0 0 1 14,4.5 H 2 A 1,1 0 0 1 2,2.5 Z M 2,7 H 14 A 1,1 0 0 1 14,9 H 2 A 1,1 0 0 1 2,7 Z M 2,11.5 H 14 A 1,1 0 0 1 14,13.5 H 2 A 1,1 0 0 1 2,11.5 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 441 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<!-- Shared with me: two people. system-users-symbolic survives only in
|
||||
Adwaita's legacy folder. -->
|
||||
<path fill="#222222" d="M 6,2.5 A 2.5,2.5 0 1 0 6,7.5 A 2.5,2.5 0 1 0 6,2.5 Z M 1.5,14.5 C 1.5,11 3.5,9 6,9 C 8.5,9 10.5,11 10.5,14.5 Z"/>
|
||||
<path fill="#222222" d="M 11,2.4 A 2.1,2.1 0 1 0 11,6.6 A 2.1,2.1 0 1 0 11,2.4 Z M 9.8,8.4 C 10.2,8.3 10.6,8.25 11,8.25 C 13.3,8.25 15,10 15,13.5 H 11.8 C 11.7,11.5 11,9.7 9.8,8.4 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 529 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<!-- Shared by me: one node passing to two others. Adwaita 50 no longer
|
||||
ships emblem-shared-symbolic. -->
|
||||
<path fill="#222222" d="M 12,1.25 A 2.25,2.25 0 1 0 12,5.75 A 2.25,2.25 0 1 0 12,1.25 Z M 4,5.75 A 2.25,2.25 0 1 0 4,10.25 A 2.25,2.25 0 1 0 4,5.75 Z"/>
|
||||
<path fill="#222222" d="M 4.37,8.65 L 12.37,4.15 L 11.63,2.85 L 3.63,7.35 Z"/>
|
||||
<path fill="#222222" d="M 12,10.25 A 2.25,2.25 0 1 0 12,14.75 A 2.25,2.25 0 1 0 12,10.25 Z"/>
|
||||
<path fill="#222222" d="M 3.63,8.65 L 11.63,13.15 L 12.37,11.85 L 4.37,7.35 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 621 B |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<!-- Syncing: two arrows chasing each other round a circle. Bundled because
|
||||
Adwaita 50 no longer ships emblem-synchronizing-symbolic. The lower arrow
|
||||
is the upper one turned half a circle, written out in plain coordinates:
|
||||
GTK's symbolic icon renderer drops a transformed group. -->
|
||||
<path fill="#222222" d="M 2,8 A 6,6 0 0 1 12.91,4.56 L 11.48,5.56 A 4.25,4.25 0 0 0 3.75,8 Z"/>
|
||||
<path fill="#222222" d="M 14.23,3.64 L 14.03,7.68 L 10.17,6.48 Z"/>
|
||||
<path fill="#222222" d="M 14,8 A 6,6 0 0 1 3.09,11.44 L 4.52,10.44 A 4.25,4.25 0 0 0 12.25,8 Z"/>
|
||||
<path fill="#222222" d="M 1.77,12.36 L 1.97,8.32 L 5.83,9.52 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 733 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<!-- Up to date: a check knocked out of a filled circle. -->
|
||||
<path fill="#222222" fill-rule="evenodd" d="M 8,1 A 7,7 0 1 0 8,15 A 7,7 0 1 0 8,1 Z M 4.1,8.6 L 5.2,7.5 L 7,9.3 L 10.8,5.4 L 11.9,6.5 L 7,11.5 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 305 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<!-- Sharing stopped: the share glyph with its last node crossed out. Not
|
||||
a no-entry sign, which reads as an error. -->
|
||||
<path fill="#222222" d="M 12,1.25 A 2.25,2.25 0 1 0 12,5.75 A 2.25,2.25 0 1 0 12,1.25 Z M 4,5.75 A 2.25,2.25 0 1 0 4,10.25 A 2.25,2.25 0 1 0 4,5.75 Z"/>
|
||||
<path fill="#222222" d="M 4.37,8.65 L 12.37,4.15 L 11.63,2.85 L 3.63,7.35 Z"/>
|
||||
<path fill="#222222" d="M 3.63,8.65 L 8.23,11.25 L 8.97,9.95 L 4.37,7.35 Z"/>
|
||||
<path fill="#222222" d="M 9.28,11.02 L 13.48,15.22 L 14.72,13.98 L 10.52,9.78 Z"/>
|
||||
<path fill="#222222" d="M 13.48,9.78 L 9.28,13.98 L 10.52,15.22 L 14.72,11.02 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 706 B |
@@ -10,6 +10,16 @@
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-filter-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-offline-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-online-only-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-cached-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-details-pane-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-link-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-map-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-menu-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-people-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-share-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-sync-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-synced-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-unshare-symbolic.svg</file>
|
||||
<!-- The Proton theme's folder: a full-colour icon from the icon theme
|
||||
can't be recoloured by CSS. -->
|
||||
<file preprocess="xml-stripblanks">scalable/places/pdfs-proton-folder.svg</file>
|
||||
@@ -23,5 +33,7 @@
|
||||
<gresource prefix="/de/nils/protondrivelinux">
|
||||
<file>style.css</file>
|
||||
<file>proton-theme.css</file>
|
||||
<!-- The search launcher's own sheet, over the same palette. -->
|
||||
<file>prompt.css</file>
|
||||
</gresource>
|
||||
</gresources>
|
||||
@@ -0,0 +1,224 @@
|
||||
/* The search launcher (pdfs-prompt). Colours come from the libadwaita
|
||||
palette, so the launcher follows the system accent, light/dark and high
|
||||
contrast like the app; the opt-in Proton theme recolours both alike. */
|
||||
|
||||
window.launcher-window {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.launcher-card {
|
||||
background-color: var(--view-bg-color);
|
||||
color: var(--view-fg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 16px 48px rgb(0 0 0 / 35%);
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
-gtk-icon-size: 20px;
|
||||
color: color-mix(in srgb, currentColor 55%, transparent);
|
||||
}
|
||||
|
||||
.search-entry {
|
||||
background: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
padding: 2px 0;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.key-hint {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
background-color: color-mix(in srgb, currentColor 8%, transparent);
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.chip-row {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 4px 14px;
|
||||
min-height: 0;
|
||||
border-radius: 999px;
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: none;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
background-color: color-mix(in srgb, currentColor 6%, transparent);
|
||||
}
|
||||
|
||||
.chip:checked {
|
||||
background-color: var(--accent-bg-color);
|
||||
color: var(--accent-fg-color);
|
||||
border-color: var(--accent-bg-color);
|
||||
}
|
||||
|
||||
/* --- results ------------------------------------------------------------ */
|
||||
|
||||
.results {
|
||||
padding: 2px 8px 10px 8px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 12px 10px 6px 10px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
-gtk-icon-size: 14px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.result-list {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.result-row {
|
||||
padding: 6px 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.result-row:hover {
|
||||
background-color: color-mix(in srgb, currentColor 5%, transparent);
|
||||
}
|
||||
|
||||
/* A soft tint rather than a solid accent bar: the text keeps its colour, so
|
||||
the highlighted match stays readable on the selected row. */
|
||||
.result-row:selected,
|
||||
.result-row:selected:hover {
|
||||
background-color: color-mix(in srgb, var(--accent-bg-color) 18%, transparent);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.result-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.result-location,
|
||||
.result-meta {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.result-row:selected .result-location,
|
||||
.result-row:selected .result-meta {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.result-badge,
|
||||
.preview-badge {
|
||||
-gtk-icon-size: 14px;
|
||||
}
|
||||
|
||||
.badge-pinned {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.badge-cached {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.badge-cloud,
|
||||
.badge-local {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* --- preview ------------------------------------------------------------ */
|
||||
|
||||
.preview {
|
||||
padding: 16px;
|
||||
background-color: color-mix(in srgb, currentColor 3%, transparent);
|
||||
}
|
||||
|
||||
.preview-thumb {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.preview-thumb .thumb {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.preview-name {
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.preview-kind {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.6;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.preview-key {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.preview-value {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.preview-action {
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.preview-shortcut {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.preview-feedback {
|
||||
margin-top: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
/* --- footer ------------------------------------------------------------- */
|
||||
|
||||
.footer {
|
||||
padding: 8px 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
statuspage.compact > scrolledwindow > viewport > box {
|
||||
margin: 24px 12px;
|
||||
}
|
||||
@@ -19,6 +19,22 @@
|
||||
.sidebar-status image.error { color: var(--error-color); }
|
||||
.sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; }
|
||||
.sidebar-account { padding: 4px 4px 4px 8px; }
|
||||
/* A count of what waits on a page, at the end of its sidebar row */
|
||||
.sidebar-badge {
|
||||
min-width: 10px;
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
background: alpha(currentColor, 0.15);
|
||||
font-size: 0.8em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Sync page status card: the state is the headline, the detail explains it */
|
||||
.sync-card { padding: 18px 24px; }
|
||||
.sync-card-icon { padding: 6px; }
|
||||
.sync-card-icon.success { color: var(--success-color); }
|
||||
.sync-card-icon.warning { color: var(--warning-color); }
|
||||
.sync-card-icon.error { color: var(--error-color); }
|
||||
|
||||
/* File browser */
|
||||
.file-grid { padding: 6px; }
|
||||
@@ -27,13 +43,11 @@
|
||||
.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-pinned { color: var(--success-color); }
|
||||
.badge-cached { color: var(--success-color); }
|
||||
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
|
||||
.browser-statusbar { background-color: alpha(currentColor, 0.025); }
|
||||
scale.browser-status-meter trough, 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; }
|
||||
scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
|
||||
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
|
||||
.dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
|
||||
.dropzone-active { border-color: var(--accent-bg-color); background: color-mix(in srgb, var(--accent-bg-color) 10%, transparent); }
|
||||
@@ -52,19 +66,19 @@ progressbar.browser-status-meter trough, progressbar.browser-status-meter progre
|
||||
.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; }
|
||||
.photo-scrubber { background: linear-gradient(to left, alpha(var(--window-bg-color), 0.92) 40%, alpha(var(--window-bg-color), 0)); }
|
||||
.photo-scrubber-knob { background-color: var(--accent-bg-color); border-radius: 2px; }
|
||||
.photo-scrubber-bubble { font-weight: 600; background-color: var(--popover-bg-color); color: var(--popover-fg-color); border-radius: 999px; padding: 4px 12px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); }
|
||||
.viewer-spinner { color: white; }
|
||||
.viewer-status { color: white; font-size: 1rem; background-color: rgba(0, 0, 0, 0.75); padding: 12px 24px; border-radius: 12px; }
|
||||
.viewer-info-panel { background-color: var(--window-bg-color); border-left: 1px solid alpha(currentColor, 0.12); }
|
||||
|
||||
/* Photos grid */
|
||||
.gallery-day { font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: alpha(currentColor, 0.65); padding: 2px 2px 6px 2px; }
|
||||
.photo-tile { padding: 0; margin: 0; min-height: 0; min-width: 0; border: none; border-radius: 4px; background: none; box-shadow: none; transition: box-shadow 180ms ease; }
|
||||
.photo-tile:hover { box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); }
|
||||
.photo-tile { padding: 0; margin: 0; min-height: 0; min-width: 0; border: none; border-radius: 4px; background: none; box-shadow: none; }
|
||||
.photo-tile:focus { outline: 2px solid var(--accent-color); outline-offset: -2px; }
|
||||
.photo-thumb { transition: transform 220ms ease; }
|
||||
.photo-tile:hover .photo-thumb { transform: scale(1.06); }
|
||||
.photo-tile:hover .photo-thumb { transform: scale(1.02); }
|
||||
.photo-check { color: white; background: rgba(0, 0, 0, 0.5); border-radius: 999px; padding: 3px; }
|
||||
.photo-check-on { background: var(--accent-bg-color); color: var(--accent-fg-color); }
|
||||
.photo-tile-selected { outline: 3px solid var(--accent-bg-color); outline-offset: -3px; }
|
||||
@@ -83,10 +97,18 @@ progressbar.browser-status-meter trough, progressbar.browser-status-meter progre
|
||||
.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; }
|
||||
.view-switch button, .view-switch toggle { padding-left: 18px; padding-right: 18px; }
|
||||
|
||||
/* Shared with Me: what a share lets me do, as a tinted pill beside the name. */
|
||||
.role-pill { padding: 2px 10px; border-radius: 999px; font-weight: 600; color: var(--accent-color); background-color: color-mix(in srgb, var(--accent-bg-color) 16%, transparent); }
|
||||
.role-pill.warning-pill { color: var(--warning-color); background-color: color-mix(in srgb, var(--warning-bg-color) 20%, transparent); }
|
||||
|
||||
/* Activity: the feed sits on the window background as one card per row, under
|
||||
* day headings, like a boxed list with its rows set apart. */
|
||||
listview.activity-feed { background: none; color: inherit; padding: 0 12px 18px 12px; }
|
||||
listview.activity-feed > row { padding: 0; margin-bottom: 6px; background: none; }
|
||||
listview.activity-feed > header { padding: 18px 6px 6px 6px; background: none; }
|
||||
listview.activity-feed row.card { border-radius: 12px; }
|
||||
|
||||
/* Photos: how many timeline filters are on, as a small badge on the Filters button. */
|
||||
.filter-count { min-width: 18px; padding: 0 5px; border-radius: 999px; font-size: 0.8em; font-weight: 700; color: var(--accent-fg-color); background-color: var(--accent-bg-color); }
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Dates and times as the app shows them, so every page words a moment the
|
||||
//! same way.
|
||||
//!
|
||||
//! - [`relative`] for "when did this happen" in lists: "Just now", "5 min
|
||||
//! ago", "Yesterday, 14:05", "Tuesday", then a date.
|
||||
//! - [`short_date`] for date columns: "Sep 23", with the year only when it is
|
||||
//! not this year.
|
||||
//! - [`full`] for tooltips: the weekday, date, year and time.
|
||||
//!
|
||||
//! The formats are `strftime` patterns that go through gettext, so they follow
|
||||
//! the language picked in Preferences, not only the system locale.
|
||||
|
||||
use gtk4::glib;
|
||||
|
||||
use crate::{gettext, gettext_f, ngettext_f};
|
||||
|
||||
/// Seconds in a day, for the "within the last week" checks.
|
||||
const DAY: i64 = 86_400;
|
||||
|
||||
/// A past moment in epoch seconds, relative to now. Empty when the time can't
|
||||
/// be read.
|
||||
pub(crate) fn relative(secs: i64) -> String {
|
||||
match (
|
||||
glib::DateTime::from_unix_local(secs),
|
||||
glib::DateTime::now_local(),
|
||||
) {
|
||||
(Ok(at), Ok(now)) => relative_to(&at, &now),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`relative`] for a fixed `now`, so it can be tested.
|
||||
pub(crate) fn relative_to(at: &glib::DateTime, now: &glib::DateTime) -> String {
|
||||
let ago = now.to_unix() - at.to_unix();
|
||||
// A clock a little ahead of ours still reads as "just now".
|
||||
if (-60..60).contains(&ago) {
|
||||
return gettext("Just now");
|
||||
}
|
||||
if (60..3600).contains(&ago) {
|
||||
let minutes = (ago / 60) as u64;
|
||||
return ngettext_f("{n} min ago", "{n} min ago", minutes, &[]);
|
||||
}
|
||||
// Translators: strftime format for a time of day, such as "14:05".
|
||||
let time = || format(at, &gettext("%H:%M"));
|
||||
if at.ymd() == now.ymd() {
|
||||
return time();
|
||||
}
|
||||
if now.add_days(-1).is_ok_and(|y| y.ymd() == at.ymd()) {
|
||||
// Translators: {time} is a time of day, such as "14:05".
|
||||
return gettext_f("Yesterday, {time}", &[("time", &time())]);
|
||||
}
|
||||
if (0..6 * DAY).contains(&ago) {
|
||||
return format(at, "%A");
|
||||
}
|
||||
short_date_to(at, now)
|
||||
}
|
||||
|
||||
/// A date in epoch seconds, without the time. Empty when the time can't be
|
||||
/// read.
|
||||
pub(crate) fn short_date(secs: i64) -> String {
|
||||
match (
|
||||
glib::DateTime::from_unix_local(secs),
|
||||
glib::DateTime::now_local(),
|
||||
) {
|
||||
(Ok(at), Ok(now)) => short_date_to(&at, &now),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`short_date`] for a fixed `now`, so it can be tested.
|
||||
pub(crate) fn short_date_to(at: &glib::DateTime, now: &glib::DateTime) -> String {
|
||||
let pattern = if at.year() == now.year() {
|
||||
// Translators: strftime format for a date in this year, such as "Sep 23".
|
||||
gettext("%b %-d")
|
||||
} else {
|
||||
// Translators: strftime format for a date in another year, such as "Sep 23, 2025".
|
||||
gettext("%b %-d, %Y")
|
||||
};
|
||||
format(at, &pattern)
|
||||
}
|
||||
|
||||
/// The whole moment in epoch seconds, for a tooltip. Empty when the time
|
||||
/// can't be read.
|
||||
pub(crate) fn full(secs: i64) -> String {
|
||||
glib::DateTime::from_unix_local(secs)
|
||||
.map(|at| {
|
||||
// Translators: strftime format for a full date and time, such as
|
||||
// "Wednesday, September 23, 2026, 14:05".
|
||||
format(&at, &gettext("%A, %B %-d, %Y, %H:%M"))
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn format(at: &glib::DateTime, pattern: &str) -> String {
|
||||
at.format(pattern)
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn at(y: i32, m: i32, d: i32, h: i32, min: i32) -> glib::DateTime {
|
||||
glib::DateTime::from_local(y, m, d, h, min, 0.0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_times_get_coarser_with_age() {
|
||||
let now = at(2026, 9, 23, 15, 0);
|
||||
assert_eq!(relative_to(&at(2026, 9, 23, 14, 59), &now), "1 min ago");
|
||||
assert_eq!(relative_to(&at(2026, 9, 23, 14, 55), &now), "5 min ago");
|
||||
assert_eq!(relative_to(&at(2026, 9, 23, 9, 5), &now), "09:05");
|
||||
assert_eq!(
|
||||
relative_to(&at(2026, 9, 22, 14, 5), &now),
|
||||
"Yesterday, 14:05"
|
||||
);
|
||||
assert_eq!(relative_to(&at(2026, 9, 20, 10, 0), &now), "Sunday");
|
||||
assert_eq!(relative_to(&at(2026, 9, 1, 10, 0), &now), "Sep 1");
|
||||
assert_eq!(relative_to(&at(2024, 3, 5, 10, 0), &now), "Mar 5, 2024");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_moment_ago_or_slightly_ahead_is_just_now() {
|
||||
let now = at(2026, 9, 23, 15, 0);
|
||||
assert_eq!(relative_to(&now, &now), "Just now");
|
||||
assert_eq!(
|
||||
relative_to(&at(2026, 9, 23, 15, 0), &now.add_seconds(30.0).unwrap()),
|
||||
"Just now"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_dates_show_the_year_only_when_it_differs() {
|
||||
let now = at(2026, 9, 23, 15, 0);
|
||||
assert_eq!(short_date_to(&at(2026, 1, 2, 8, 0), &now), "Jan 2");
|
||||
assert_eq!(short_date_to(&at(2025, 12, 31, 8, 0), &now), "Dec 31, 2025");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! The window's side of the daemon's event feed ([`Request::Subscribe`]).
|
||||
//!
|
||||
//! The window used to ask the daemon for its status every two seconds and let
|
||||
//! pages re-read themselves on timers. Now one thread follows the feed and hands
|
||||
//! each event to the main loop: a status or transfers snapshot is painted as it
|
||||
//! comes, and a change reloads the page on screen if the page shows it. A
|
||||
//! window nobody can see keeps its changes for later and catches up when it is
|
||||
//! focused again.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use pdfs_core::control::{Event, Feed, follow};
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// How long the conflict list waits after a change before it is read again.
|
||||
/// Listing walks every node the daemon knows, and a sync pass can report
|
||||
/// changes many times a second.
|
||||
const CONFLICTS_DEBOUNCE: Duration = Duration::from_secs(3);
|
||||
|
||||
/// How long the import's state waits after its progress moved before it is
|
||||
/// read again.
|
||||
const TAKEOUT_DEBOUNCE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// How old the invitations may be when the window is focused. Another person
|
||||
/// inviting this account sends the daemon no event, so focus is when they are
|
||||
/// looked for.
|
||||
const INVITATIONS_ON_FOCUS: Duration = Duration::from_secs(60);
|
||||
|
||||
/// What the feed keeps between events.
|
||||
#[derive(Default)]
|
||||
struct FeedState {
|
||||
/// What changed while the window was out of sight.
|
||||
deferred: RefCell<BTreeSet<Topic>>,
|
||||
/// Whether a conflict read is already waiting out [`CONFLICTS_DEBOUNCE`].
|
||||
conflicts_pending: Rc<Cell<bool>>,
|
||||
/// Whether an import read is already waiting out [`TAKEOUT_DEBOUNCE`].
|
||||
takeout_pending: Rc<Cell<bool>>,
|
||||
}
|
||||
|
||||
/// Follow the daemon for as long as `window` is open.
|
||||
pub(crate) fn follow_daemon(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
||||
let (tx, rx) = async_channel::bounded(64);
|
||||
let socket = ui.dirs.control_socket();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("pdfs-feed".into())
|
||||
.spawn(move || follow(&socket, |feed| tx.send_blocking(feed).is_ok()));
|
||||
if let Err(e) = spawned {
|
||||
tracing::error!("cannot start the event feed: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
let state = Rc::new(FeedState::default());
|
||||
let ui_feed = ui.clone();
|
||||
let state_feed = state.clone();
|
||||
let window_feed = window.downgrade();
|
||||
let rx_feed = rx.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
while let Ok(feed) = rx_feed.recv().await {
|
||||
let hidden = window_feed.upgrade().is_none_or(|w| window_hidden(&w));
|
||||
on_feed(&ui_feed, &state_feed, feed, hidden);
|
||||
}
|
||||
});
|
||||
|
||||
// Catch up as soon as the user comes back to the window.
|
||||
let ui_focus = ui.clone();
|
||||
window.connect_is_active_notify(move |window| {
|
||||
if !window.is_active() {
|
||||
return;
|
||||
}
|
||||
let topics: Vec<Topic> = state.deferred.take().into_iter().collect();
|
||||
if !topics.is_empty() {
|
||||
follow_changes(&ui_focus, &state, &topics);
|
||||
}
|
||||
refresh_quota(&ui_focus);
|
||||
refresh_invitations_badge(&ui_focus, INVITATIONS_ON_FOCUS);
|
||||
});
|
||||
// Closing the channel ends the loop above, and the thread with the next
|
||||
// event it tries to hand over.
|
||||
window.connect_close_request(move |_| {
|
||||
rx.close();
|
||||
glib::Propagation::Proceed
|
||||
});
|
||||
}
|
||||
|
||||
fn on_feed(ui: &Rc<Ui>, state: &Rc<FeedState>, feed: Feed, hidden: bool) {
|
||||
match feed {
|
||||
// The daemon follows up with its whole state, so there is nothing to
|
||||
// ask for here.
|
||||
Feed::Connected | Feed::Event(Event::Heartbeat) => {}
|
||||
Feed::Event(Event::Status(status)) => {
|
||||
paint_status(ui, *status);
|
||||
// Uploads are what move the quota, and they move the status too.
|
||||
refresh_quota(ui);
|
||||
}
|
||||
// Painted even out of sight: the end of a batch is what sends the
|
||||
// "Sync complete" notification.
|
||||
Feed::Event(Event::Transfers { items, jobs }) => {
|
||||
repaint_transfers(ui, &items, &jobs);
|
||||
follow_takeout(ui, state, &jobs);
|
||||
}
|
||||
Feed::Event(Event::Changed { topics }) if hidden => {
|
||||
state.deferred.borrow_mut().extend(topics);
|
||||
}
|
||||
Feed::Event(Event::Changed { topics }) => follow_changes(ui, state, &topics),
|
||||
// Ask once, which paints "not connected" or "not responding", and clear
|
||||
// progress that can no longer move.
|
||||
Feed::Disconnected => {
|
||||
refresh_status(ui);
|
||||
repaint_transfers(ui, &[], &[]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reload what `topics` says changed: the sidebar badges wherever the user
|
||||
/// is, and the page on screen if it shows one of them.
|
||||
fn follow_changes(ui: &Rc<Ui>, state: &FeedState, topics: &[Topic]) {
|
||||
let changed = |topic| topics.contains(&topic);
|
||||
if changed(Topic::Conflicts) {
|
||||
let ui = ui.clone();
|
||||
debounce(&state.conflicts_pending, CONFLICTS_DEBOUNCE, move || {
|
||||
refresh_conflicts(&ui);
|
||||
if activity_visible(&ui) {
|
||||
refresh_activity_conflicts(&ui, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
if changed(Topic::Shares) {
|
||||
refresh_invitations_badge(ui, Duration::ZERO);
|
||||
}
|
||||
match ui.stack.visible_child_name().as_deref() {
|
||||
Some("browser") if changed(Topic::Files) => load_browser(ui),
|
||||
// The timeline is left alone: reloading it would throw away the place
|
||||
// the user scrolled to. F5 reloads it on request.
|
||||
Some("gallery")
|
||||
if changed(Topic::Photos)
|
||||
&& ui.gallery.content.visible_child_name().as_deref() == Some("albums") =>
|
||||
{
|
||||
load_albums(ui)
|
||||
}
|
||||
Some("trash") if changed(Topic::Trash) => load_trash(ui),
|
||||
Some("shared") if changed(Topic::Shares) => load_shared(ui),
|
||||
Some("sharedbyme") if changed(Topic::Shares) || changed(Topic::Files) => {
|
||||
load_shared_by_me(ui)
|
||||
}
|
||||
Some("devices") if changed(Topic::Devices) => load_devices(ui),
|
||||
Some("locations") => follow_sync_view(ui, topics),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the import's state again while its progress moves, and once more after
|
||||
/// it stops, which is how a finished import is noticed. Nothing is read while
|
||||
/// no import runs and the page is elsewhere.
|
||||
fn follow_takeout(ui: &Rc<Ui>, state: &FeedState, jobs: &[JobItem]) {
|
||||
let importing = jobs.iter().any(|job| job.title == IMPORT_JOB);
|
||||
if importing
|
||||
|| ui.takeout.running.get()
|
||||
|| ui.stack.visible_child_name().as_deref() == Some("takeout")
|
||||
{
|
||||
let ui = ui.clone();
|
||||
debounce(&state.takeout_pending, TAKEOUT_DEBOUNCE, move || {
|
||||
refresh_takeout(&ui)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` once `delay` has passed, unless a run is already waiting. The run
|
||||
/// sees everything that happened up to it, so nothing is missed by skipping.
|
||||
fn debounce(pending: &Rc<Cell<bool>>, delay: Duration, f: impl FnOnce() + 'static) {
|
||||
if pending.replace(true) {
|
||||
return;
|
||||
}
|
||||
let pending = pending.clone();
|
||||
glib::timeout_add_local_once(delay, move || {
|
||||
pending.set(false);
|
||||
f();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
//! Activity entries as the feed words them.
|
||||
//!
|
||||
//! The daemon logs each entry in plain English for its own log and the CLI:
|
||||
//! a target ("report.pdf", "3 item(s)", "sync conflict in Docs") and a detail
|
||||
//! ("to /Documents", "was notes.txt", "2 uploaded, 1 deleted"). This module
|
||||
//! reads the shapes the daemon writes and says them again in the user's
|
||||
//! language. A shape it does not know, which is mostly an error message,
|
||||
//! shows as the daemon wrote it.
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// What a feed row says about one entry.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Described {
|
||||
pub(crate) title: String,
|
||||
/// Subtitle parts, shown joined by " · ".
|
||||
pub(crate) details: Vec<String>,
|
||||
}
|
||||
|
||||
/// The title and subtitle parts for `entry`.
|
||||
pub(crate) fn describe(entry: &ActivityEntry) -> Described {
|
||||
let title = entry_title(entry);
|
||||
let mut details = Vec::new();
|
||||
if entry.kind == ActivityKind::Conflict
|
||||
&& let Some(stamp) = conflict_stamp(&entry.target)
|
||||
{
|
||||
let when = dates::relative(stamp);
|
||||
if !when.is_empty() {
|
||||
// Translators: {time} is when a conflicting copy was made, such as
|
||||
// "Sep 21" or "5 min ago".
|
||||
details.push(gettext_f("Made {time}", &[("time", &when)]));
|
||||
}
|
||||
}
|
||||
if let Some(detail) = detail_label(entry) {
|
||||
details.insert(0, detail);
|
||||
}
|
||||
Described { title, details }
|
||||
}
|
||||
|
||||
/// The row title: what happened to what, one sentence per case so a
|
||||
/// translator can place the name where their language needs it.
|
||||
fn entry_title(entry: &ActivityEntry) -> String {
|
||||
use ActivityKind::*;
|
||||
let target = entry.target.as_str();
|
||||
let ok = entry.ok;
|
||||
|
||||
// Entries whose target is a count or a fixed phrase rather than a name.
|
||||
match (entry.kind, target) {
|
||||
(Upload, "Google Photos import") => {
|
||||
return if ok {
|
||||
gettext("Imported from Google Photos")
|
||||
} else {
|
||||
gettext("Couldn't import from Google Photos")
|
||||
};
|
||||
}
|
||||
(Upload, "bulk upload") => return gettext("Couldn't upload files"),
|
||||
(Upload, "add sync folder") => return gettext("Couldn't add a sync folder"),
|
||||
(Download, "restore folders") => return gettext("Couldn't restore folders"),
|
||||
(Unshare, "shared item") => return gettext("Left a shared item"),
|
||||
_ => {}
|
||||
}
|
||||
if matches!(entry.kind, Restore | DeleteForever | EmptyTrash)
|
||||
&& let Some(n) = leading_count(target, &["item(s)", "item", "items"])
|
||||
{
|
||||
return match entry.kind {
|
||||
Restore => ngettext_f("Restored {n} item", "Restored {n} items", n, &[]),
|
||||
DeleteForever => ngettext_f(
|
||||
"Deleted {n} item forever",
|
||||
"Deleted {n} items forever",
|
||||
n,
|
||||
&[],
|
||||
),
|
||||
_ => ngettext_f(
|
||||
"Emptied the Trash ({n} item)",
|
||||
"Emptied the Trash ({n} items)",
|
||||
n,
|
||||
&[],
|
||||
),
|
||||
};
|
||||
}
|
||||
if entry.kind == Download
|
||||
&& let Some(rest) = target.strip_prefix("restored ")
|
||||
&& let Some(n) = leading_count(rest, &["folder(s)"])
|
||||
{
|
||||
return ngettext_f("Restored {n} folder", "Restored {n} folders", n, &[]);
|
||||
}
|
||||
if entry.kind == Sync
|
||||
&& let Some(title) = sync_title(target)
|
||||
{
|
||||
return title;
|
||||
}
|
||||
|
||||
let Some(name) = target_name(target) else {
|
||||
return match entry.kind {
|
||||
Share => gettext("Shared an item"),
|
||||
PublicLink => gettext("Created a link to an item"),
|
||||
Unshare if entry.detail == "link removed" => gettext("Removed a link to an item"),
|
||||
Unshare => gettext("Stopped sharing an item"),
|
||||
_ => activity_title(entry.kind, ok, target),
|
||||
};
|
||||
};
|
||||
if entry.kind == Unshare && entry.detail == "link removed" {
|
||||
// Translators: {name} is a file or folder name.
|
||||
return gettext_f("Removed the link to {name}", &[("name", &name)]);
|
||||
}
|
||||
if entry.kind == Conflict
|
||||
&& let Some(base) = conflict_base(&name)
|
||||
{
|
||||
// Translators: {name} is the file the conflicting copy was made from.
|
||||
return gettext_f("Conflicting copy of {name}", &[("name", &base)]);
|
||||
}
|
||||
activity_title(entry.kind, ok, &name)
|
||||
}
|
||||
|
||||
/// The title of a sync pass or mode switch, whose target is a phrase.
|
||||
fn sync_title(target: &str) -> Option<String> {
|
||||
if let Some(name) = target.strip_prefix("sync conflict in ") {
|
||||
// Translators: {name} is a synced folder's name.
|
||||
return Some(gettext_f(
|
||||
"Conflicting changes in {name}",
|
||||
&[("name", name)],
|
||||
));
|
||||
}
|
||||
if let Some(name) = target.strip_prefix("sync incomplete for ") {
|
||||
// Translators: {name} is a synced folder's name.
|
||||
return Some(gettext_f(
|
||||
"Some items in {name} didn't sync",
|
||||
&[("name", name)],
|
||||
));
|
||||
}
|
||||
if let Some(name) = target.strip_prefix("sync failed for ") {
|
||||
// Translators: {name} is a synced folder's name.
|
||||
return Some(gettext_f("Couldn't sync {name}", &[("name", name)]));
|
||||
}
|
||||
if let Some(name) = target.strip_prefix("couldn't switch ") {
|
||||
// Translators: {name} is a synced folder's name.
|
||||
return Some(gettext_f(
|
||||
"Couldn't change how {name} syncs",
|
||||
&[("name", name)],
|
||||
));
|
||||
}
|
||||
if let Some(path) = target.strip_suffix(" is now on-demand") {
|
||||
let name = file_name(path);
|
||||
// Translators: {name} is a folder's name.
|
||||
return Some(gettext_f("{name} is now online only", &[("name", name)]));
|
||||
}
|
||||
if let Some(path) = target.strip_suffix(" is mirroring again; downloading") {
|
||||
let name = file_name(path);
|
||||
// Translators: {name} is a folder's name.
|
||||
return Some(gettext_f("{name} is synced again", &[("name", name)]));
|
||||
}
|
||||
if target.starts_with("already ") {
|
||||
return Some(gettext("Sync mode was already set"));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The row title for an activity on a named item.
|
||||
pub(crate) fn activity_title(kind: ActivityKind, ok: bool, name: &str) -> String {
|
||||
let args = [("name", name)];
|
||||
match (kind, ok) {
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::Upload, true) => gettext_f("Uploaded {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::Upload, false) => gettext_f("Couldn't upload {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::Download, true) => gettext_f("Downloaded {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::Download, false) => gettext_f("Couldn't download {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::Sync, true) => gettext_f("Synced {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::Sync, false) => gettext_f("Couldn't sync {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::Rename, true) => gettext_f("Renamed {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::Rename, false) => gettext_f("Couldn't rename {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::Move, true) => gettext_f("Moved {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::Move, false) => gettext_f("Couldn't move {name}", &args),
|
||||
// Translators: {name} is a folder name.
|
||||
(ActivityKind::CreateFolder, true) => gettext_f("Created folder {name}", &args),
|
||||
// Translators: {name} is a folder name.
|
||||
(ActivityKind::CreateFolder, false) => gettext_f("Couldn't create folder {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::Trash, true) => gettext_f("Moved {name} to the Trash", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::Trash, false) => gettext_f("Couldn't move {name} to the Trash", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::Restore, true) => gettext_f("Restored {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::Restore, false) => gettext_f("Couldn't restore {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::DeleteForever, true) => gettext_f("Deleted {name} forever", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::DeleteForever, false) => gettext_f("Couldn't delete {name}", &args),
|
||||
(ActivityKind::EmptyTrash, true) => gettext("Emptied the Trash"),
|
||||
(ActivityKind::EmptyTrash, false) => gettext("Couldn't empty the Trash"),
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::Share, true) => gettext_f("Shared {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::Share, false) => gettext_f("Couldn't share {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::PublicLink, true) => gettext_f("Created a link to {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::PublicLink, false) => gettext_f("Couldn't create a link to {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::Unshare, true) => gettext_f("Stopped sharing {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::Unshare, false) => gettext_f("Couldn't stop sharing {name}", &args),
|
||||
// Translators: {name} is the name of the conflicting copy.
|
||||
(ActivityKind::Conflict, _) => gettext_f("Conflicting copy {name}", &args),
|
||||
}
|
||||
}
|
||||
|
||||
/// The subtitle's first part: the daemon's detail in the user's language, or
|
||||
/// `None` when it only repeats the title.
|
||||
fn detail_label(entry: &ActivityEntry) -> Option<String> {
|
||||
let detail = entry.detail.trim();
|
||||
let name = |s: &str| file_name(s).to_string();
|
||||
match detail {
|
||||
""
|
||||
| "uploaded"
|
||||
| "created"
|
||||
| "trashed"
|
||||
| "trashed from the mount"
|
||||
| "stopped sharing"
|
||||
| "left"
|
||||
| "link created"
|
||||
| "link removed"
|
||||
| "access removed"
|
||||
| "removed" => {
|
||||
return None;
|
||||
}
|
||||
"removed on Drive" => return Some(gettext("Removed on Proton Drive")),
|
||||
"removed locally" => return Some(gettext("Removed on this computer")),
|
||||
"replaced by a rename from the mount" => {
|
||||
return Some(gettext("Replaced by a renamed file"));
|
||||
}
|
||||
"destination folder no longer exists; the file was left in place" => {
|
||||
return Some(gettext(
|
||||
"The destination folder no longer exists, so the file stayed where it was",
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if entry.kind == ActivityKind::Upload
|
||||
&& entry.target == "Google Photos import"
|
||||
&& entry.ok
|
||||
&& let Some(summary) = import_summary(detail)
|
||||
{
|
||||
return Some(summary);
|
||||
}
|
||||
if entry.kind == ActivityKind::Sync
|
||||
&& let Some(summary) = sync_summary(detail)
|
||||
{
|
||||
return Some(summary);
|
||||
}
|
||||
if let Some(dest) = detail.strip_prefix("to ") {
|
||||
// Translators: {name} is the folder an item was moved into.
|
||||
return Some(gettext_f("To {name}", &[("name", &folder_name(dest))]));
|
||||
}
|
||||
if let Some(old) = detail.strip_prefix("was ") {
|
||||
// Translators: {name} is an item's previous name.
|
||||
return Some(gettext_f("Previously {name}", &[("name", &name(old))]));
|
||||
}
|
||||
if detail.starts_with("restored version ") {
|
||||
return Some(gettext("Restored an earlier version"));
|
||||
}
|
||||
if detail.starts_with("deleted version ") {
|
||||
return Some(gettext("Deleted an earlier version"));
|
||||
}
|
||||
if let Some(rest) = detail.strip_prefix("saved version ")
|
||||
&& let Some((_, path)) = rest.split_once(" to ")
|
||||
{
|
||||
// Translators: {path} is where an earlier version was saved.
|
||||
return Some(gettext_f(
|
||||
"Saved an earlier version to {path}",
|
||||
&[("path", &tilde_path(path))],
|
||||
));
|
||||
}
|
||||
if let Some(copy) = detail.strip_prefix("kept both; the copy is now ") {
|
||||
// Translators: {name} is the new name of the conflicting copy.
|
||||
return Some(gettext_f(
|
||||
"Kept both; the copy is now {name}",
|
||||
&[("name", copy)],
|
||||
));
|
||||
}
|
||||
if let Some(base) = detail
|
||||
.strip_prefix("kept ")
|
||||
.and_then(|rest| rest.strip_suffix("; the copy is in Trash"))
|
||||
{
|
||||
// Translators: {name} is the file that was kept.
|
||||
return Some(gettext_f(
|
||||
"Kept {name}; the copy is in the Trash",
|
||||
&[("name", base)],
|
||||
));
|
||||
}
|
||||
if detail.starts_with("kept the conflict copy ") {
|
||||
return Some(gettext("Kept the conflicting copy"));
|
||||
}
|
||||
if let Some(base) = detail.strip_prefix("kept ") {
|
||||
// Translators: {name} is the file that was kept.
|
||||
return Some(gettext_f("Kept {name}", &[("name", base)]));
|
||||
}
|
||||
if let Some(base) = detail.strip_prefix("auto-removed duplicate of ") {
|
||||
// Translators: {name} is the file the removed copy was identical to.
|
||||
return Some(gettext_f(
|
||||
"Same as {name}, so it was removed",
|
||||
&[("name", base)],
|
||||
));
|
||||
}
|
||||
if let Some(alt) = detail.strip_prefix("destination already had that name; moved as ") {
|
||||
// Translators: {name} is the name the item was given instead.
|
||||
return Some(gettext_f(
|
||||
"The name was taken, so it was moved as {name}",
|
||||
&[("name", alt)],
|
||||
));
|
||||
}
|
||||
if let Some(alt) = detail.strip_prefix("name was taken remotely; renamed to ") {
|
||||
// Translators: {name} is the name the item was given instead.
|
||||
return Some(gettext_f(
|
||||
"The name was taken on Proton Drive, so it was renamed to {name}",
|
||||
&[("name", alt)],
|
||||
));
|
||||
}
|
||||
if let Some(alt) = detail.strip_prefix("name was taken remotely; created as ") {
|
||||
// Translators: {name} is the name the item was given instead.
|
||||
return Some(gettext_f(
|
||||
"The name was taken on Proton Drive, so it was created as {name}",
|
||||
&[("name", alt)],
|
||||
));
|
||||
}
|
||||
if let Some(alt) =
|
||||
detail.strip_prefix("its folder was trashed remotely; created in the root as ")
|
||||
{
|
||||
// Translators: {name} is the name the item was given.
|
||||
return Some(gettext_f(
|
||||
"Its folder was moved to the Trash on Proton Drive, so it was created at the top as {name}",
|
||||
&[("name", alt)],
|
||||
));
|
||||
}
|
||||
if let Some(base) = detail.strip_prefix("differs from ") {
|
||||
// Translators: {name} is the file the conflicting copy was made from.
|
||||
return Some(gettext_f("Differs from {name}", &[("name", base)]));
|
||||
}
|
||||
if let Some(base) = detail
|
||||
.strip_prefix("no original ")
|
||||
.and_then(|rest| rest.strip_suffix(" to reconcile against"))
|
||||
{
|
||||
// Translators: {name} is the file the conflicting copy was made from.
|
||||
return Some(gettext_f("{name} no longer exists", &[("name", base)]));
|
||||
}
|
||||
if let Some(base) = detail
|
||||
.strip_prefix("identical to ")
|
||||
.and_then(|rest| rest.split_once(';').map(|(base, _)| base))
|
||||
{
|
||||
// Translators: {name} is the file the conflicting copy was made from.
|
||||
return Some(gettext_f("Same as {name}", &[("name", base)]));
|
||||
}
|
||||
if let Some((n, role)) = detail
|
||||
.split_once(" recipient(s) as ")
|
||||
.and_then(|(n, role)| Some((n.parse::<u64>().ok()?, role)))
|
||||
{
|
||||
return Some(match role {
|
||||
"viewer" => ngettext_f("{n} person can view", "{n} people can view", n, &[]),
|
||||
"editor" => ngettext_f("{n} person can edit", "{n} people can edit", n, &[]),
|
||||
_ => ngettext_f("{n} person invited", "{n} people invited", n, &[]),
|
||||
});
|
||||
}
|
||||
if let Some(n) = leading_count(detail, &["file(s) kept as conflict copies"]) {
|
||||
return Some(ngettext_f(
|
||||
"{n} file kept as a conflicting copy",
|
||||
"{n} files kept as conflicting copies",
|
||||
n,
|
||||
&[],
|
||||
));
|
||||
}
|
||||
if let Some(n) = leading_count(detail, &["item(s) failed; will retry"]) {
|
||||
return Some(ngettext_f(
|
||||
"{n} item failed and will be tried again",
|
||||
"{n} items failed and will be tried again",
|
||||
n,
|
||||
&[],
|
||||
));
|
||||
}
|
||||
Some(capitalize(detail))
|
||||
}
|
||||
|
||||
/// A sync pass summary such as "2 uploaded, 1 folder(s) created", as a
|
||||
/// list of counts. `None` when a part is not one the daemon writes.
|
||||
fn sync_summary(detail: &str) -> Option<String> {
|
||||
let mut parts = Vec::new();
|
||||
for part in detail.split(", ") {
|
||||
let (n, label) = part.split_once(' ')?;
|
||||
let n = n.parse::<u64>().ok()?;
|
||||
parts.push(match label {
|
||||
// Translators: part of a list summing up a sync pass.
|
||||
"uploaded" => ngettext_f("{n} uploaded", "{n} uploaded", n, &[]),
|
||||
// Translators: part of a list summing up a sync pass.
|
||||
"downloaded" => ngettext_f("{n} downloaded", "{n} downloaded", n, &[]),
|
||||
// Translators: part of a list summing up a sync pass.
|
||||
"folder(s) created" => ngettext_f("{n} folder created", "{n} folders created", n, &[]),
|
||||
// Translators: part of a list summing up a sync pass.
|
||||
"deleted" => ngettext_f("{n} deleted", "{n} deleted", n, &[]),
|
||||
// Translators: part of a list summing up a sync pass.
|
||||
"conflicted" => ngettext_f("{n} in conflict", "{n} in conflict", n, &[]),
|
||||
// Translators: part of a list summing up a sync pass; the files are
|
||||
// still open in another app.
|
||||
"deferred (open for write)" => ngettext_f("{n} still open", "{n} still open", n, &[]),
|
||||
// Translators: part of a list summing up a sync pass.
|
||||
"failed" => ngettext_f("{n} failed", "{n} failed", n, &[]),
|
||||
_ => return None,
|
||||
});
|
||||
}
|
||||
Some(parts.join(", "))
|
||||
}
|
||||
|
||||
/// A Google Photos import summary such as "40 uploaded · 2 already there".
|
||||
fn import_summary(detail: &str) -> Option<String> {
|
||||
let mut parts = Vec::new();
|
||||
for part in detail.split(" · ") {
|
||||
if part == "cancelled" {
|
||||
parts.push(gettext("Cancelled"));
|
||||
continue;
|
||||
}
|
||||
let (n, label) = part.split_once(' ')?;
|
||||
let n = n.parse::<u64>().ok()?;
|
||||
parts.push(match label {
|
||||
"uploaded" => ngettext_f("{n} uploaded", "{n} uploaded", n, &[]),
|
||||
// Translators: photos an import skipped because they were already
|
||||
// in Proton Drive.
|
||||
"already there" => ngettext_f("{n} already there", "{n} already there", n, &[]),
|
||||
"albums" => ngettext_f("{n} album", "{n} albums", n, &[]),
|
||||
"failed" => ngettext_f("{n} failed", "{n} failed", n, &[]),
|
||||
_ => return None,
|
||||
});
|
||||
}
|
||||
Some(parts.join(" · "))
|
||||
}
|
||||
|
||||
/// The number in front of `text` when the rest is one of `units`, as in
|
||||
/// "3 item(s)".
|
||||
fn leading_count(text: &str, units: &[&str]) -> Option<u64> {
|
||||
let (n, unit) = text.split_once(' ')?;
|
||||
units.contains(&unit).then_some(())?;
|
||||
n.parse().ok()
|
||||
}
|
||||
|
||||
/// The name to show for a target: the last part of a path, or `None` when
|
||||
/// the daemon logged a node id because it had no path.
|
||||
fn target_name(target: &str) -> Option<String> {
|
||||
let looks_like_uid =
|
||||
target.len() >= 40 && target.matches('~').count() == 1 && !target.contains(['/', ' ', '.']);
|
||||
if target.is_empty() || looks_like_uid {
|
||||
return None;
|
||||
}
|
||||
Some(file_name(target).to_string())
|
||||
}
|
||||
|
||||
/// The folder a destination path names, with the root read as "My files".
|
||||
fn folder_name(dest: &str) -> String {
|
||||
let name = file_name(dest.trim_end_matches('/'));
|
||||
if name.is_empty() {
|
||||
gettext("My files")
|
||||
} else {
|
||||
name.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// The file a `name (sync-conflict <stamp>).ext` copy was made from.
|
||||
pub(crate) fn conflict_base(name: &str) -> Option<String> {
|
||||
let (start, close) = conflict_marker(name)?;
|
||||
Some(format!("{}{}", &name[..start], &name[close + 1..]))
|
||||
}
|
||||
|
||||
/// When a conflicting copy was made, from the stamp in its name.
|
||||
fn conflict_stamp(name: &str) -> Option<i64> {
|
||||
let (start, close) = conflict_marker(name)?;
|
||||
let inner = &name[start + MARKER.len()..close];
|
||||
inner.split('-').next()?.parse().ok()
|
||||
}
|
||||
|
||||
const MARKER: &str = " (sync-conflict ";
|
||||
|
||||
/// Where the ` (sync-conflict <stamp>[-<n>])` marker starts and where its
|
||||
/// closing parenthesis is. The same shape the daemon reads back.
|
||||
fn conflict_marker(name: &str) -> Option<(usize, usize)> {
|
||||
let start = name.find(MARKER)?;
|
||||
let after = start + MARKER.len();
|
||||
let close = after + name[after..].find(')')?;
|
||||
let inner = &name[after..close];
|
||||
let mut parts = inner.splitn(2, '-');
|
||||
let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
|
||||
if !digits(parts.next()?) || parts.next().is_some_and(|s| !digits(s)) {
|
||||
return None;
|
||||
}
|
||||
Some((start, close))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(kind: ActivityKind, target: &str, detail: &str, ok: bool) -> ActivityEntry {
|
||||
ActivityEntry {
|
||||
time: 0,
|
||||
kind,
|
||||
target: target.into(),
|
||||
detail: detail.into(),
|
||||
ok,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn details_that_repeat_the_title_are_dropped() {
|
||||
let d = describe(&entry(ActivityKind::Upload, "a.txt", "uploaded", true));
|
||||
assert_eq!(d.title, "Uploaded a.txt");
|
||||
assert!(d.details.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_and_counts_read_as_names_and_numbers() {
|
||||
let d = describe(&entry(
|
||||
ActivityKind::Move,
|
||||
"/mnt/a.txt",
|
||||
"to /mnt/Docs",
|
||||
true,
|
||||
));
|
||||
assert_eq!(d.title, "Moved a.txt");
|
||||
assert_eq!(d.details, vec!["To Docs"]);
|
||||
let d = describe(&entry(ActivityKind::Restore, "3 item(s)", "", true));
|
||||
assert_eq!(d.title, "Restored 3 items");
|
||||
let d = describe(&entry(ActivityKind::EmptyTrash, "1 item", "", true));
|
||||
assert_eq!(d.title, "Emptied the Trash (1 item)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failures_say_what_could_not_happen() {
|
||||
let d = describe(&entry(
|
||||
ActivityKind::Upload,
|
||||
"a.txt",
|
||||
"quota exceeded",
|
||||
false,
|
||||
));
|
||||
assert_eq!(d.title, "Couldn't upload a.txt");
|
||||
assert_eq!(d.details, vec!["Quota exceeded"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_passes_are_summed_up() {
|
||||
let d = describe(&entry(
|
||||
ActivityKind::Sync,
|
||||
"Docs",
|
||||
"2 uploaded, 1 folder(s) created",
|
||||
true,
|
||||
));
|
||||
assert_eq!(d.title, "Synced Docs");
|
||||
assert_eq!(d.details, vec!["2 uploaded, 1 folder created"]);
|
||||
let d = describe(&entry(
|
||||
ActivityKind::Sync,
|
||||
"sync incomplete for Docs",
|
||||
"2 item(s) failed; will retry",
|
||||
false,
|
||||
));
|
||||
assert_eq!(d.title, "Some items in Docs didn't sync");
|
||||
assert_eq!(d.details, vec!["2 items failed and will be tried again"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conflicting_copy_names_its_original() {
|
||||
let d = describe(&entry(
|
||||
ActivityKind::Conflict,
|
||||
"notes (sync-conflict 1700000000).txt",
|
||||
"differs from notes.txt",
|
||||
false,
|
||||
));
|
||||
assert_eq!(d.title, "Conflicting copy of notes.txt");
|
||||
assert_eq!(d.details[0], "Differs from notes.txt");
|
||||
assert_eq!(d.details.len(), 2);
|
||||
assert_eq!(conflict_base("a (sync-conflict 12-2)"), Some("a".into()));
|
||||
assert_eq!(conflict_base("a (sync-conflict x).txt"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_ids_are_not_shown_as_names() {
|
||||
let uid = format!("{}~{}", "A".repeat(30), "B".repeat(30));
|
||||
let d = describe(&entry(
|
||||
ActivityKind::Share,
|
||||
&uid,
|
||||
"1 recipient(s) as viewer",
|
||||
true,
|
||||
));
|
||||
assert_eq!(d.title, "Shared an item");
|
||||
assert_eq!(d.details, vec!["1 person can view"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_details_show_as_written() {
|
||||
let d = describe(&entry(
|
||||
ActivityKind::Download,
|
||||
"a.txt",
|
||||
"network timeout",
|
||||
false,
|
||||
));
|
||||
assert_eq!(d.details, vec!["Network timeout"]);
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
||||
&ui,
|
||||
"network-offline-symbolic",
|
||||
&gettext("Not connected"),
|
||||
&gettext("The Proton Drive mount service didn't respond."),
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -286,7 +286,7 @@ pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
&gettext("Couldn't create the album"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -351,7 +351,11 @@ fn run_album_request(ui: &Rc<Ui>, request: Request, done: String, failed: String
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, &failed, &message, kind)
|
||||
}
|
||||
_ => toast_error(&ui, &failed, &gettext("The mount service didn't respond.")),
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
&failed,
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -380,7 +384,7 @@ pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
||||
toast_error(
|
||||
&ui,
|
||||
&gettext("Couldn't load albums"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -487,7 +491,7 @@ fn add_to_album(ui: &Rc<Ui>, album: String, photos: Vec<String>) {
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
&gettext("Couldn't add to the album"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -559,7 +563,7 @@ pub(crate) fn remove_from_album(ui: &Rc<Ui>, album: &AlbumInfo, photos: Vec<Stri
|
||||
toast_error(
|
||||
&ui,
|
||||
&gettext("Couldn't remove from the album"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -614,7 +618,7 @@ pub(crate) fn open_album(ui: &Rc<Ui>, album: AlbumInfo) {
|
||||
// would silently do something else. The Photos/Albums switcher goes too: an
|
||||
// album is a level below it, and back is the way out.
|
||||
ui.gallery.filters.set_visible(false);
|
||||
ui.gallery.view_switch.set_visible(false);
|
||||
ui.gallery.view_switch.widget().set_visible(false);
|
||||
ui.gallery.upload.set_visible(false);
|
||||
ui.gallery.back.set_visible(true);
|
||||
// A stale kind/date filter would otherwise be sent with the album request.
|
||||
@@ -632,7 +636,7 @@ pub(crate) fn close_album(ui: &Rc<Ui>) {
|
||||
return;
|
||||
}
|
||||
ui.gallery.title.set_title(&gettext("Photos"));
|
||||
ui.gallery.view_switch.set_visible(true);
|
||||
ui.gallery.view_switch.widget().set_visible(true);
|
||||
ui.gallery.upload.set_visible(true);
|
||||
ui.gallery.back.set_visible(false);
|
||||
}
|
||||
@@ -649,17 +653,15 @@ fn albums_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) {
|
||||
/// whole content area; back leaves an open album for the grid it came from.
|
||||
pub(crate) fn wire_albums(ui: &Rc<Ui>) {
|
||||
let ui_albums = ui.clone();
|
||||
ui.gallery.albums_btn.clone().connect_toggled(move |btn| {
|
||||
if btn.is_active() {
|
||||
ui.gallery.view_switch.connect_changed(move |view| {
|
||||
if view == VIEW_ALBUMS {
|
||||
show_albums(&ui_albums);
|
||||
}
|
||||
});
|
||||
|
||||
// Only the button being switched *on* acts: the group fires `toggled` for
|
||||
// the one going off too, and acting on both would load twice.
|
||||
let ui_photos = ui.clone();
|
||||
ui.gallery.photos_btn.clone().connect_toggled(move |btn| {
|
||||
if !btn.is_active() {
|
||||
ui.gallery.view_switch.connect_changed(move |view| {
|
||||
if view != VIEW_TIMELINE {
|
||||
return;
|
||||
}
|
||||
close_album(&ui_photos);
|
||||
|
||||
@@ -1,85 +1,115 @@
|
||||
use crate::*;
|
||||
|
||||
pub(crate) struct DevicesState {
|
||||
// Devices page: two sections (synced folders + other devices), rebuilt
|
||||
// wholesale on each load.
|
||||
// Computers page: one list of the account's computers, this one first, and
|
||||
// a read-only browser over another computer's backup.
|
||||
/// "list", "browse", or "status" while the page loads or can't be read.
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
/// Rows added last time, removed before the next paint (adw groups have no
|
||||
/// clear-all).
|
||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
||||
pub(crate) sync_group: adw::PreferencesGroup,
|
||||
/// The single "This computer" summary row. The per-folder rows moved to the
|
||||
/// Locations page, which owns every local path.
|
||||
pub(crate) sync_rows: RefCell<Vec<gtk4::Widget>>,
|
||||
/// "Rename" action in the "This computer" header. Insensitive until the
|
||||
/// device list identifies this machine's own device.
|
||||
pub(crate) rename_this: gtk4::Button,
|
||||
/// This machine's own device `(uid, name)`, from the last device list. The
|
||||
/// current device is filtered out of the "Other computers" rows, so this is
|
||||
/// where its rename target lives. `None` until identified.
|
||||
pub(crate) this_device: RefCell<Option<(String, String)>>,
|
||||
pub(crate) inflight: Cell<bool>,
|
||||
pub(crate) loaded_at: Cell<Option<Instant>>,
|
||||
/// The browse view's files.
|
||||
pub(crate) files: FileList,
|
||||
/// The browse view: "list", or "empty".
|
||||
pub(crate) files_content: gtk4::Stack,
|
||||
/// The breadcrumb trail from "Computers" down to the current folder.
|
||||
pub(crate) crumb: gtk4::Box,
|
||||
/// Grid or list for the browse view; hidden on the device list.
|
||||
pub(crate) layout: gtk4::Button,
|
||||
/// Where the browse view is, as `(uid, name)`: the computer first, then each
|
||||
/// folder below it. Empty = the device list. A backup has no path in the
|
||||
/// mount, so descending is uid-addressed and the stack is the breadcrumb.
|
||||
pub(crate) nav: RefCell<Vec<(String, String)>>,
|
||||
/// The [`DevicesState::nav`] the page on screen was painted for, or `None`
|
||||
/// before the first paint.
|
||||
pub(crate) listed: RefCell<Option<Vec<(String, String)>>>,
|
||||
/// Runs the loads; the rows stay up while one runs.
|
||||
pub(crate) loader: Rc<Loader>,
|
||||
}
|
||||
|
||||
impl DevicesState {
|
||||
pub(crate) fn new(widgets: &DevicesWidgets) -> Self {
|
||||
DevicesState {
|
||||
content: widgets.content.clone(),
|
||||
status: widgets.status.clone(),
|
||||
retry: widgets.retry.clone(),
|
||||
group: widgets.group.clone(),
|
||||
rows: RefCell::new(Vec::new()),
|
||||
files: widgets.files.clone(),
|
||||
files_content: widgets.files_content.clone(),
|
||||
crumb: widgets.crumb.clone(),
|
||||
layout: widgets.layout.clone(),
|
||||
nav: RefCell::new(Vec::new()),
|
||||
listed: RefCell::new(None),
|
||||
loader: Loader::new(&widgets.content),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Widgets the Devices page's load/repaint touch.
|
||||
pub(crate) struct DevicesWidgets {
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
/// "This computer" — this machine's device identity and a pointer to its
|
||||
/// folders on the Locations page.
|
||||
pub(crate) sync_group: adw::PreferencesGroup,
|
||||
/// "Rename" in the "This computer" header — renames this machine's device.
|
||||
pub(crate) rename_this: gtk4::Button,
|
||||
/// "Other computers" — the account's *other* registered devices. This
|
||||
/// machine's own device is deliberately not among them; see
|
||||
/// [`repaint_devices`].
|
||||
/// Every computer on the account, this one first.
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
pub(crate) files: FileList,
|
||||
pub(crate) files_content: gtk4::Stack,
|
||||
/// Goes up one folder, and from a computer's top level back to the list.
|
||||
pub(crate) back: gtk4::Button,
|
||||
pub(crate) crumb: gtk4::Box,
|
||||
pub(crate) layout: gtk4::Button,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) refresh: gtk4::Button,
|
||||
}
|
||||
|
||||
/// The Computers page: a "This computer" section naming the device this machine
|
||||
/// backs up to (renamable from its header), plus an "Other computers" section
|
||||
/// listing the account's other registered devices. The synced folders
|
||||
/// themselves live on the Sync page.
|
||||
/// The Computers page: every computer backing up to the account, with this one
|
||||
/// first. This computer's row leads to its folders on the Sync page; another
|
||||
/// computer's row opens its backup read-only, in the same list as My files.
|
||||
pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
||||
let refresh = refresh_button();
|
||||
|
||||
// This page is about device *identity* — which computer this is, which other
|
||||
// computers back up to the account, and how to adopt one. The folders
|
||||
// themselves are local paths, so they live on the Sync page; keeping a
|
||||
// second copy of that list here would be two places to change the same mode.
|
||||
let sync_group = adw::PreferencesGroup::builder()
|
||||
.title(gettext("This computer"))
|
||||
.description(gettext("The computer this machine backs up as."))
|
||||
.build();
|
||||
// Rename control for *this* machine's device, in the section header. The
|
||||
// current device is filtered out of "Other computers", so this is the only
|
||||
// place it can be renamed; insensitive until the device list identifies it.
|
||||
let rename_this = gtk4::Button::builder()
|
||||
.icon_name("document-edit-symbolic")
|
||||
.tooltip_text(gettext("Rename this computer"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.sensitive(false)
|
||||
.build();
|
||||
rename_this.add_css_class("flat");
|
||||
sync_group.set_header_suffix(Some(&rename_this));
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title(gettext("Other computers"))
|
||||
.description(gettext("Other computers backing up to this account."))
|
||||
.build();
|
||||
|
||||
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
groups.append(&sync_group);
|
||||
groups.append(&group);
|
||||
let clamp = adw::Clamp::builder().child(&groups).build();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let clamp = adw::Clamp::builder().child(&group).build();
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
|
||||
// Browse: a path bar over the list, as on Shared with me.
|
||||
let files = FileList::new();
|
||||
files.show_list(true);
|
||||
let layout = layout_button(&files);
|
||||
layout.set_visible(false);
|
||||
let back = gtk4::Button::builder()
|
||||
.icon_name("go-previous-symbolic")
|
||||
.tooltip_text(gettext("Back"))
|
||||
.build();
|
||||
back.add_css_class("flat");
|
||||
let crumb = gtk4::Box::new(gtk4::Orientation::Horizontal, 2);
|
||||
crumb.set_valign(gtk4::Align::Center);
|
||||
let crumb_scroll = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::External)
|
||||
.vscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.hexpand(true)
|
||||
.child(&crumb)
|
||||
.build();
|
||||
let path_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
path_bar.append(&back);
|
||||
path_bar.append(&crumb_scroll);
|
||||
let files_empty = adw::StatusPage::builder()
|
||||
.icon_name("folder-open-symbolic")
|
||||
.title(gettext("This folder is empty"))
|
||||
.description(gettext("Nothing in this folder has been backed up."))
|
||||
.vexpand(true)
|
||||
.build();
|
||||
files_empty.add_css_class("compact");
|
||||
let files_content = gtk4::Stack::new();
|
||||
files_content.add_named(&files.views, Some("list"));
|
||||
files_content.add_named(&files_empty, Some("empty"));
|
||||
let browse = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
browse.append(&path_bar);
|
||||
browse.append(&files_content);
|
||||
|
||||
let retry = gtk4::Button::builder()
|
||||
.label(gettext("Retry"))
|
||||
.halign(gtk4::Align::Center)
|
||||
@@ -98,6 +128,7 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
||||
content.set_vexpand(true);
|
||||
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
||||
content.add_named(&scroll, Some("list"));
|
||||
content.add_named(&browse, Some("browse"));
|
||||
content.add_named(&status, Some("status"));
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
@@ -107,35 +138,66 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
||||
inner.set_margin_end(18);
|
||||
inner.append(&content);
|
||||
let (frame, header, _) = page_frame(&gettext("Computers"), &inner);
|
||||
header.pack_end(&refresh);
|
||||
header.pack_end(&layout);
|
||||
|
||||
(
|
||||
frame.upcast(),
|
||||
DevicesWidgets {
|
||||
content,
|
||||
status,
|
||||
sync_group,
|
||||
rename_this,
|
||||
group,
|
||||
files,
|
||||
files_content,
|
||||
back,
|
||||
crumb,
|
||||
layout,
|
||||
retry,
|
||||
refresh,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Install the Devices page's retry button and the Rename action.
|
||||
pub(crate) fn wire_devices(ui: &Rc<Ui>, retry: >k4::Button) {
|
||||
/// Install the browse view's file list and the page's buttons.
|
||||
pub(crate) fn wire_devices(ui: &Rc<Ui>, widgets: &DevicesWidgets) {
|
||||
let files = &ui.devices.files;
|
||||
files.wire(
|
||||
ui,
|
||||
FileListBehavior {
|
||||
activate: open_device_entry,
|
||||
entry_menu: device_entry_menu,
|
||||
bulk_menu: device_bulk_menu,
|
||||
background_menu: device_background_menu,
|
||||
badges: false,
|
||||
drag_and_drop: false,
|
||||
},
|
||||
);
|
||||
files
|
||||
.column_view
|
||||
.append_column(&text_column(&pgettext("column", "Modified"), |e| {
|
||||
if e.modified > 0 {
|
||||
dates::short_date(e.modified)
|
||||
} else {
|
||||
"—".to_string()
|
||||
}
|
||||
}));
|
||||
files
|
||||
.column_view
|
||||
.append_column(&text_column(&pgettext("column", "Size"), |e| {
|
||||
if e.is_dir {
|
||||
"—".to_string()
|
||||
} else {
|
||||
human_bytes(e.size)
|
||||
}
|
||||
}));
|
||||
|
||||
let ui_back = ui.clone();
|
||||
widgets.back.connect_clicked(move |_| {
|
||||
ui_back.devices.nav.borrow_mut().pop();
|
||||
load_devices(&ui_back);
|
||||
});
|
||||
let ui_retry = ui.clone();
|
||||
retry.connect_clicked(move |_| {
|
||||
service::restart();
|
||||
load_devices(&ui_retry);
|
||||
});
|
||||
let ui_ren = ui.clone();
|
||||
ui.devices.rename_this.connect_clicked(move |_| {
|
||||
if let Some((uid, name)) = ui_ren.devices.this_device.borrow().clone() {
|
||||
prompt_rename_device(&ui_ren, &uid, &name);
|
||||
}
|
||||
});
|
||||
widgets
|
||||
.retry
|
||||
.connect_clicked(move |_| restart_service_then(&ui_retry, load_devices));
|
||||
}
|
||||
|
||||
/// Show a status page in place of the Devices list.
|
||||
@@ -147,21 +209,17 @@ pub(crate) fn devices_status(ui: &Rc<Ui>, icon: &str, title: &str, description:
|
||||
ui.devices.content.set_visible_child_name("status");
|
||||
}
|
||||
|
||||
/// Fetch this machine's synced folders and the account's other devices, then
|
||||
/// repaint both sections. The two requests are chained so a single unreachable
|
||||
/// daemon collapses the whole page to a status view.
|
||||
/// Load the view [`DevicesState::nav`] points at: the device list, or a folder
|
||||
/// of another computer's backup.
|
||||
pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
||||
if ui.devices.inflight.get() {
|
||||
cancel_file_thumbnails(ui);
|
||||
let nav = ui.devices.nav.borrow().clone();
|
||||
ui.devices.layout.set_visible(!nav.is_empty());
|
||||
if !nav.is_empty() {
|
||||
load_device_folder(ui, &nav);
|
||||
return;
|
||||
}
|
||||
ui.devices.inflight.set(true);
|
||||
devices_status(
|
||||
ui,
|
||||
"computer-symbolic",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading your computers."),
|
||||
false,
|
||||
);
|
||||
let ticket = begin_devices_load(ui, "computer-symbolic", gettext("Reading your computers."));
|
||||
ui.busy_begin();
|
||||
// The two lists are independent and the daemon serves requests concurrently,
|
||||
// so fire both up front and collect them, rather than paying two round trips
|
||||
@@ -172,213 +230,361 @@ pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
||||
glib::spawn_future_local(async move {
|
||||
let sync = rx.recv().await;
|
||||
let devices_reply = rx2.recv().await;
|
||||
ui.busy_end();
|
||||
if !ticket.is_current() {
|
||||
return;
|
||||
}
|
||||
let folders = match sync {
|
||||
Ok(Ok(Response::SyncFolders { items })) => items,
|
||||
// The daemon answered but not as expected — treat as empty and carry
|
||||
// on to the devices list rather than failing the whole page.
|
||||
Ok(Ok(_)) => Vec::new(),
|
||||
Ok(Err(_)) | Err(_) => {
|
||||
ui.busy_end();
|
||||
ui.devices.inflight.set(false);
|
||||
ui.devices.loaded_at.set(None);
|
||||
devices_unreachable(&ui);
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Daemon reachable: show the list and paint the "This computer" section.
|
||||
ui.devices.content.set_visible_child_name("list");
|
||||
repaint_this_computer(&ui, &folders);
|
||||
|
||||
let devices = match devices_reply {
|
||||
Ok(Ok(Response::Devices { items })) => items,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
ui.busy_end();
|
||||
ui.devices.inflight.set(false);
|
||||
repaint_devices(&ui, &devices);
|
||||
ui.devices.loaded_at.set(Some(Instant::now()));
|
||||
ui.devices.content.set_visible_child_name("list");
|
||||
repaint_devices(&ui, &devices, &folders);
|
||||
repaint_device_crumb(&ui, &[]);
|
||||
*ui.devices.listed.borrow_mut() = Some(Vec::new());
|
||||
});
|
||||
}
|
||||
|
||||
/// List one folder of another computer's backup. The computer's own top level
|
||||
/// is its restorable folders; below that, any folder lists by uid.
|
||||
fn load_device_folder(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
||||
let ticket = begin_devices_load(ui, "folder-symbolic", gettext("Reading this folder."));
|
||||
ui.busy_begin();
|
||||
let (uid, _) = nav.last().cloned().unwrap_or_default();
|
||||
let request = if nav.len() == 1 {
|
||||
Request::ListDeviceRestorableFolders { device: uid }
|
||||
} else {
|
||||
Request::ListSharedFolder { uid }
|
||||
};
|
||||
let rx = spawn_request(ui.dirs.control_socket(), request);
|
||||
let ui = ui.clone();
|
||||
let nav = nav.to_vec();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
if !ticket.is_current() {
|
||||
return;
|
||||
}
|
||||
let entries = match result {
|
||||
Ok(Ok(Response::Entries { entries })) => entries,
|
||||
Ok(Ok(Response::RestorableFolders { items })) => {
|
||||
items.into_iter().map(restorable_entry).collect()
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
// The folder or the computer is gone: fall back a level rather
|
||||
// than stranding the page on a dead uid.
|
||||
ui.devices.nav.borrow_mut().pop();
|
||||
toast_failure(&ui, &gettext("Couldn't open folder"), &message, kind);
|
||||
load_devices(&ui);
|
||||
return;
|
||||
}
|
||||
_ => {
|
||||
devices_unreachable(&ui);
|
||||
return;
|
||||
}
|
||||
};
|
||||
ui.devices.content.set_visible_child_name("browse");
|
||||
repaint_device_crumb(&ui, &nav);
|
||||
*ui.devices.listed.borrow_mut() = Some(nav);
|
||||
if entries.is_empty() {
|
||||
ui.devices.files.model.remove_all();
|
||||
ui.devices.files_content.set_visible_child_name("empty");
|
||||
} else {
|
||||
ui.devices.files_content.set_visible_child_name("list");
|
||||
replace_items(&ui.devices.files.model, &entries);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A computer's top-level folder as a browsable entry.
|
||||
fn restorable_entry(folder: RestorableFolder) -> DirEntry {
|
||||
DirEntry {
|
||||
name: folder.name,
|
||||
is_dir: true,
|
||||
uid: folder.remote_uid,
|
||||
..DirEntry::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a load of the view [`DevicesState::nav`] points at. A reload of the
|
||||
/// view on screen keeps it up and usable; moving elsewhere greys it out until
|
||||
/// the new view arrives.
|
||||
fn begin_devices_load(ui: &Rc<Ui>, icon: &'static str, description: String) -> LoadTicket {
|
||||
let ui_p = ui.clone();
|
||||
let placeholder = move || {
|
||||
devices_status(&ui_p, icon, &gettext("Loading…"), &description, false);
|
||||
};
|
||||
if *ui.devices.listed.borrow() == Some(ui.devices.nav.borrow().clone()) {
|
||||
ui.devices.loader.refresh(placeholder)
|
||||
} else {
|
||||
ui.devices.listed.borrow_mut().take();
|
||||
ui.devices.loader.replace(placeholder)
|
||||
}
|
||||
}
|
||||
|
||||
/// The daemon didn't answer the Devices page.
|
||||
pub(crate) fn devices_unreachable(ui: &Rc<Ui>) {
|
||||
if service::is_failed() || !service::is_active() {
|
||||
devices_status(
|
||||
ui,
|
||||
"network-offline-symbolic",
|
||||
&gettext("Not connected"),
|
||||
&gettext("The Proton Drive mount service isn't running."),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
devices_status(
|
||||
ui,
|
||||
"folder-remote-symbolic",
|
||||
&gettext("Connecting…"),
|
||||
&gettext("Waiting for the Proton Drive mount service to come up."),
|
||||
false,
|
||||
);
|
||||
let ui = ui.clone();
|
||||
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
|
||||
if ui.stack.visible_child_name().as_deref() == Some("devices") {
|
||||
load_devices(&ui);
|
||||
}
|
||||
});
|
||||
service_unreachable(ui, "devices", devices_status, load_devices);
|
||||
}
|
||||
|
||||
/// Rebuild the "Other computers" section from a fresh listing. Empty is a normal
|
||||
/// state (this machine may be the only device), so it shows a placeholder row
|
||||
/// rather than collapsing the page.
|
||||
/// Rebuild the device list: this computer first, then the others.
|
||||
///
|
||||
/// This machine's own device is filtered out. Deleting a device deletes its root
|
||||
/// folder — every file backed up from it — and for *this* machine that would also
|
||||
/// pull the ground out from under the synced folders listed directly above, so it
|
||||
/// is not offered as a peer of "remove some other laptop". Removing this
|
||||
/// computer's backup means removing its folders, each of which asks about its
|
||||
/// cloud copy on its own terms.
|
||||
pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
|
||||
/// This computer's row offers no Remove. Deleting a device deletes its root
|
||||
/// folder — every file backed up from it — and for this machine that would also
|
||||
/// pull the ground out from under its synced folders, so it is not offered as a
|
||||
/// peer of "remove some other laptop". Removing this computer's backup means
|
||||
/// removing its folders, each of which asks about its cloud copy on its own
|
||||
/// terms.
|
||||
pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo], folders: &[SyncFolderInfo]) {
|
||||
for row in ui.devices.rows.borrow_mut().drain(..) {
|
||||
ui.devices.group.remove(&row);
|
||||
}
|
||||
// Identify this machine's own device so the "This computer" header's Rename
|
||||
// action has a target. It never appears in the rows below, so this is the
|
||||
// only place its name/uid is captured.
|
||||
match devices.iter().find(|d| d.this_device) {
|
||||
Some(me) => {
|
||||
*ui.devices.this_device.borrow_mut() = Some((me.uid.clone(), me.name.clone()));
|
||||
ui.devices
|
||||
.sync_group
|
||||
// Translators: {name} is the name of this computer's device.
|
||||
.set_description(Some(&gettext_f(
|
||||
"Backing up as “{name}”.",
|
||||
&[("name", &me.name)],
|
||||
)));
|
||||
ui.devices.rename_this.set_sensitive(true);
|
||||
ui.devices
|
||||
.rename_this
|
||||
// Translators: {name} is the name of this computer's device.
|
||||
.set_tooltip_text(Some(&gettext_f(
|
||||
"Rename this computer ({name})",
|
||||
&[("name", &me.name)],
|
||||
)));
|
||||
}
|
||||
None => {
|
||||
*ui.devices.this_device.borrow_mut() = None;
|
||||
ui.devices.rename_this.set_sensitive(false);
|
||||
}
|
||||
}
|
||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||
let me = devices.iter().find(|d| d.this_device);
|
||||
rows.push(this_computer_row(ui, me, folders).upcast());
|
||||
let others: Vec<&DeviceInfo> = devices.iter().filter(|d| !d.this_device).collect();
|
||||
for dev in &others {
|
||||
rows.push(other_computer_row(ui, dev).upcast());
|
||||
}
|
||||
if others.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(gettext("No other computers"))
|
||||
.subtitle(gettext("Desktop apps syncing to this account appear here."))
|
||||
.subtitle(gettext(
|
||||
"Install Proton Drive on another computer to see it here.",
|
||||
))
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||
ui.devices.group.add(&row);
|
||||
*ui.devices.rows.borrow_mut() = vec![row.upcast()];
|
||||
return;
|
||||
}
|
||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||
for dev in others {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&dev.name)
|
||||
.subtitle(device_subtitle(dev))
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||
// Adoption is how a reinstalled or renamed machine re-attaches to the
|
||||
// device it used to be, instead of registering a duplicate. It only
|
||||
// makes sense on *another* computer's row, which is the only place this
|
||||
// loop paints.
|
||||
let (ui_ren, uid_ren, name_ren) = (ui.clone(), dev.uid.clone(), dev.name.clone());
|
||||
let (ui_ad, uid_ad, name_ad) = (ui.clone(), dev.uid.clone(), dev.name.clone());
|
||||
let (ui_rm, uid_rm, name_rm) = (ui.clone(), dev.uid.clone(), dev.name.clone());
|
||||
let (ui_rs, uid_rs, name_rs) = (ui.clone(), dev.uid.clone(), dev.name.clone());
|
||||
row.add_suffix(&more_menu_button(vec![
|
||||
(
|
||||
&gettext("Restore to This Computer…"),
|
||||
Box::new(move || {
|
||||
prompt_restore_folders(&ui_rs, Some((uid_rs.clone(), name_rs.clone())))
|
||||
}),
|
||||
),
|
||||
(
|
||||
&gettext("Rename…"),
|
||||
Box::new(move || prompt_rename_device(&ui_ren, &uid_ren, &name_ren)),
|
||||
),
|
||||
(
|
||||
&gettext("Use This Computer's Identity…"),
|
||||
Box::new(move || prompt_adopt_device(&ui_ad, &uid_ad, &name_ad)),
|
||||
),
|
||||
(
|
||||
&gettext("Remove Computer…"),
|
||||
Box::new(move || prompt_remove_device(&ui_rm, &uid_rm, &name_rm)),
|
||||
),
|
||||
]));
|
||||
ui.devices.group.add(&row);
|
||||
let get = gtk4::LinkButton::with_label(PROTON_DRIVE_DOWNLOAD, &gettext("Get Proton Drive"));
|
||||
get.set_valign(gtk4::Align::Center);
|
||||
row.add_suffix(&get);
|
||||
rows.push(row.upcast());
|
||||
}
|
||||
for row in &rows {
|
||||
ui.devices.group.add(row);
|
||||
}
|
||||
*ui.devices.rows.borrow_mut() = rows;
|
||||
}
|
||||
|
||||
/// A device row's subtitle: its platform, and when it last synced. A device that
|
||||
/// has never synced says so — an unexplained missing date reads as a bug, and
|
||||
/// "never" is the fact that tells the user this computer isn't backing anything up.
|
||||
pub(crate) fn device_subtitle(dev: &DeviceInfo) -> String {
|
||||
match dev.last_sync {
|
||||
// Translators: {platform} is the device's platform (such as "Linux"), {time} a date and time.
|
||||
Some(secs) if secs > 0 => gettext_f(
|
||||
"{platform} · last synced {time}",
|
||||
&[
|
||||
("platform", &dev.device_type),
|
||||
("time", &activity_time(secs)),
|
||||
],
|
||||
),
|
||||
// Translators: {platform} is the device's platform, such as "Linux".
|
||||
_ => gettext_f(
|
||||
"{platform} · never synced",
|
||||
&[("platform", &dev.device_type)],
|
||||
),
|
||||
/// Where the Proton Drive apps for other systems are.
|
||||
const PROTON_DRIVE_DOWNLOAD: &str = "https://proton.me/drive/download";
|
||||
|
||||
/// This computer's row: its device name with a "This computer" pill, and how
|
||||
/// many folders it backs up. It leads to those folders on the Sync page, which
|
||||
/// owns every local path. `me` is `None` until the daemon has registered this
|
||||
/// machine as a device.
|
||||
fn this_computer_row(
|
||||
ui: &Rc<Ui>,
|
||||
me: Option<&DeviceInfo>,
|
||||
folders: &[SyncFolderInfo],
|
||||
) -> adw::ActionRow {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(me.map_or_else(|| gettext("This computer"), |d| d.name.clone()))
|
||||
.subtitle(this_computer_subtitle(folders))
|
||||
.activatable(true)
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||
if me.is_some() {
|
||||
let pill = gtk4::Label::new(Some(&gettext("This computer")));
|
||||
pill.add_css_class("role-pill");
|
||||
pill.add_css_class("caption");
|
||||
pill.set_valign(gtk4::Align::Center);
|
||||
row.add_suffix(&pill);
|
||||
}
|
||||
let mut menu = ActionMenu::new();
|
||||
if let Some(me) = me {
|
||||
let (ui_c, uid, name) = (ui.clone(), me.uid.clone(), me.name.clone());
|
||||
menu.item(&gettext("Rename…"), move || {
|
||||
prompt_rename_device(&ui_c, &uid, &name)
|
||||
});
|
||||
}
|
||||
let ui_c = ui.clone();
|
||||
menu.item(&gettext("Restore Folders…"), move || {
|
||||
prompt_restore_folders(&ui_c, None)
|
||||
});
|
||||
row.add_suffix(&menu.button());
|
||||
row.add_suffix(>k4::Image::from_icon_name("go-next-symbolic"));
|
||||
let ui_c = ui.clone();
|
||||
row.connect_activated(move |_| {
|
||||
ui_c.locations.views.set_visible_child_name("folders");
|
||||
ui_c.stack.set_visible_child_name("locations");
|
||||
});
|
||||
row
|
||||
}
|
||||
|
||||
/// Another computer's row. Opening it browses that computer's backup.
|
||||
fn other_computer_row(ui: &Rc<Ui>, dev: &DeviceInfo) -> adw::ActionRow {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&dev.name)
|
||||
.subtitle(device_subtitle(dev))
|
||||
.activatable(true)
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||
let (uid, name) = (dev.uid.clone(), dev.name.clone());
|
||||
let mut menu = ActionMenu::new();
|
||||
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
||||
menu.item(&gettext("Browse Files"), move || {
|
||||
browse_device(&ui_c, &uid_c, &name_c)
|
||||
});
|
||||
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
||||
menu.item(&gettext("Restore to This Computer…"), move || {
|
||||
prompt_restore_folders(&ui_c, Some((uid_c.clone(), name_c.clone())))
|
||||
});
|
||||
menu.section();
|
||||
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
||||
menu.item(&gettext("Rename…"), move || {
|
||||
prompt_rename_device(&ui_c, &uid_c, &name_c)
|
||||
});
|
||||
// Adoption is how a reinstalled or renamed machine re-attaches to the device
|
||||
// it used to be, instead of registering a duplicate. It only makes sense on
|
||||
// another computer's row.
|
||||
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
||||
menu.item(&gettext("Continue This Backup Here…"), move || {
|
||||
prompt_adopt_device(&ui_c, &uid_c, &name_c)
|
||||
});
|
||||
menu.section();
|
||||
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
||||
menu.item(&gettext("Remove…"), move || {
|
||||
prompt_remove_device(&ui_c, &uid_c, &name_c)
|
||||
});
|
||||
row.add_suffix(&menu.button());
|
||||
row.add_suffix(>k4::Image::from_icon_name("go-next-symbolic"));
|
||||
let ui_c = ui.clone();
|
||||
row.connect_activated(move |_| browse_device(&ui_c, &uid, &name));
|
||||
row
|
||||
}
|
||||
|
||||
/// Open another computer's backup at its top level.
|
||||
fn browse_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
*ui.devices.nav.borrow_mut() = vec![(uid.to_string(), name.to_string())];
|
||||
load_devices(ui);
|
||||
}
|
||||
|
||||
/// Rebuild the breadcrumb trail for `nav`: "Computers", then the computer and
|
||||
/// each folder down to the current one, which is a plain heading.
|
||||
fn repaint_device_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
||||
let crumb = &ui.devices.crumb;
|
||||
while let Some(child) = crumb.first_child() {
|
||||
crumb.remove(&child);
|
||||
}
|
||||
crumb.append(&device_crumb_node(
|
||||
ui,
|
||||
&gettext("Computers"),
|
||||
0,
|
||||
nav.is_empty(),
|
||||
));
|
||||
for (i, (_, name)) in nav.iter().enumerate() {
|
||||
let sep = gtk4::Label::new(Some("›"));
|
||||
sep.add_css_class("dim-label");
|
||||
crumb.append(&sep);
|
||||
crumb.append(&device_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the "This computer" section: one row naming the device this machine
|
||||
/// backs up to, and how many folders it carries.
|
||||
///
|
||||
/// The per-folder rows (mode switch, sync now, remove) live on the Locations
|
||||
/// page. They are local paths, and Locations is the one page that owns those;
|
||||
/// two lists of the same folders would be two places to flip the same switch.
|
||||
pub(crate) fn repaint_this_computer(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
|
||||
for row in ui.devices.sync_rows.borrow_mut().drain(..) {
|
||||
ui.devices.sync_group.remove(&row);
|
||||
/// One breadcrumb segment: a heading for the current folder, or a flat button
|
||||
/// that goes back up to `depth` levels below the device list.
|
||||
fn device_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
|
||||
if current {
|
||||
let l = gtk4::Label::builder()
|
||||
.label(label)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
||||
.build();
|
||||
l.add_css_class("heading");
|
||||
return l.upcast();
|
||||
}
|
||||
let button = gtk4::Button::builder().label(label).build();
|
||||
button.add_css_class("flat");
|
||||
let ui = ui.clone();
|
||||
button.connect_clicked(move |_| {
|
||||
ui.devices.nav.borrow_mut().truncate(depth);
|
||||
load_devices(&ui);
|
||||
});
|
||||
button.upcast()
|
||||
}
|
||||
|
||||
/// Folders open in place; files download and open with the default app.
|
||||
fn open_device_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
if entry.is_dir {
|
||||
ui.devices
|
||||
.nav
|
||||
.borrow_mut()
|
||||
.push((entry.uid.clone(), entry.name.clone()));
|
||||
load_devices(ui);
|
||||
} else {
|
||||
open_shared_file(ui, &entry.uid, &entry.name);
|
||||
}
|
||||
}
|
||||
|
||||
fn device_entry_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||
let mut menu = ActionMenu::new();
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
menu.item(&pgettext("verb", "Open"), move || {
|
||||
open_device_entry(&ui_c, &entry_c)
|
||||
});
|
||||
menu
|
||||
}
|
||||
|
||||
fn device_bulk_menu(_ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu {
|
||||
let mut menu = ActionMenu::new();
|
||||
menu.labelled_section(&ngettext_f(
|
||||
"{n} selected",
|
||||
"{n} selected",
|
||||
entries.len() as u64,
|
||||
&[],
|
||||
));
|
||||
menu
|
||||
}
|
||||
|
||||
fn device_background_menu(ui: &Rc<Ui>) -> ActionMenu {
|
||||
let mut menu = ActionMenu::new();
|
||||
let ui_c = ui.clone();
|
||||
menu.item(&gettext("Select All"), move || {
|
||||
ui_c.devices.files.selection.select_all();
|
||||
});
|
||||
let ui_c = ui.clone();
|
||||
menu.item(&gettext("Refresh"), move || reload_current_page(&ui_c));
|
||||
menu
|
||||
}
|
||||
|
||||
/// A device row's subtitle: its platform, and when it last backed up. A device
|
||||
/// that never did says so — an unexplained missing date reads as a bug, and
|
||||
/// "no backups yet" is the fact that tells the user this computer isn't backing
|
||||
/// anything up.
|
||||
pub(crate) fn device_subtitle(dev: &DeviceInfo) -> String {
|
||||
let platform = platform_label(&dev.device_type);
|
||||
match dev.last_sync {
|
||||
// Translators: {platform} is the device's platform (such as "Linux"), {time} a relative time such as "5 minutes ago".
|
||||
Some(secs) if secs > 0 => gettext_f(
|
||||
"{platform} · last backup {time}",
|
||||
&[("platform", platform), ("time", &dates::relative(secs))],
|
||||
),
|
||||
// Translators: {platform} is the device's platform, such as "Linux".
|
||||
_ => gettext_f("{platform} · no backups yet", &[("platform", platform)]),
|
||||
}
|
||||
}
|
||||
|
||||
/// The platform as its maker spells it. The API's names are enum variants.
|
||||
fn platform_label(device_type: &str) -> &str {
|
||||
match device_type {
|
||||
"MacOs" => "macOS",
|
||||
other => other,
|
||||
}
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(gettext("This computer"))
|
||||
.subtitle(this_computer_subtitle(folders))
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||
let manage = gtk4::Button::builder()
|
||||
.label(gettext("Open Sync"))
|
||||
.tooltip_text(gettext("Manage this computer's folders and mountpoint"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
manage.add_css_class("flat");
|
||||
let ui_go = ui.clone();
|
||||
manage.connect_clicked(move |_| ui_go.stack.set_visible_child_name("locations"));
|
||||
row.add_suffix(&manage);
|
||||
let ui_rs = ui.clone();
|
||||
row.add_suffix(&more_menu_button(vec![(
|
||||
&gettext("Restore Folders…"),
|
||||
Box::new(move || prompt_restore_folders(&ui_rs, None)),
|
||||
)]));
|
||||
ui.devices.sync_group.add(&row);
|
||||
*ui.devices.sync_rows.borrow_mut() = vec![row.upcast()];
|
||||
}
|
||||
|
||||
/// How many folders this machine backs up, and whether any of them needs
|
||||
/// attention — the one fact worth surfacing away from the folder list itself.
|
||||
pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
|
||||
if folders.is_empty() {
|
||||
return gettext("No folders backed up yet — add one on the Sync page.");
|
||||
return gettext("No folders backed up yet");
|
||||
}
|
||||
let count = ngettext_f(
|
||||
"{n} folder backed up",
|
||||
@@ -391,11 +597,7 @@ pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
|
||||
.filter(|f| f.state == "error" || f.state == "conflict")
|
||||
.count();
|
||||
match attention {
|
||||
// Translators: {folders} is "N folders backed up".
|
||||
0 => gettext_f(
|
||||
"{folders} · manage them on the Sync page",
|
||||
&[("folders", &count)],
|
||||
),
|
||||
0 => count,
|
||||
// Translators: {folders} is "N folders backed up"; {n} counts the folders with a problem.
|
||||
n => ngettext_f(
|
||||
"{folders} · {n} needs attention",
|
||||
@@ -489,7 +691,7 @@ pub(crate) fn prompt_add_sync_folder(ui: &Rc<Ui>) {
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
&gettext("Couldn't add folder"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -529,7 +731,7 @@ pub(crate) fn prompt_restore_folders(ui: &Rc<Ui>, device: Option<(String, String
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
&gettext("Couldn't list folders to restore"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -577,7 +779,7 @@ fn show_restore_picker(
|
||||
.child(&group)
|
||||
.build();
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Restore folders"))
|
||||
.heading(gettext("Restore Folders"))
|
||||
.body(restore_picker_body(
|
||||
device.as_ref().map(|(_, n)| n.as_str()),
|
||||
))
|
||||
@@ -668,7 +870,7 @@ pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &'static str) {
|
||||
Ok(Ok(Response::Ok { .. })) => toast(
|
||||
&ui,
|
||||
&if mode == "ondemand" {
|
||||
gettext("Folder will switch to on-demand")
|
||||
gettext("Folder will switch to online only")
|
||||
} else {
|
||||
gettext("Folder will download and stay synced")
|
||||
},
|
||||
@@ -679,7 +881,7 @@ pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &'static str) {
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
&gettext("Couldn't change mode"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
reload_sync_pages(&ui);
|
||||
@@ -710,7 +912,7 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
|
||||
let body = if ondemand {
|
||||
// Translators: {path} is a local folder path.
|
||||
gettext_f(
|
||||
"Stop syncing “{path}”?\n\nThis folder is on-demand: its files live in Proton Drive, not on this disk, so the folder will be empty once it is unmounted. To keep a local copy, cancel, turn off On-demand, and wait for the download to finish before removing it.",
|
||||
"Stop syncing “{path}”?\n\nThis folder is online only: its files live in Proton Drive, not on this disk, so the folder will be empty once it stops syncing. To keep a local copy, cancel, switch the folder to Synced, and wait for the download to finish before removing it.",
|
||||
&[("path", path)],
|
||||
)
|
||||
} else {
|
||||
@@ -721,7 +923,7 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
|
||||
)
|
||||
};
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Stop syncing folder"))
|
||||
.heading(gettext("Stop Syncing Folder?"))
|
||||
.body(body)
|
||||
.build();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
@@ -818,7 +1020,7 @@ pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
let group = adw::PreferencesGroup::new();
|
||||
group.add(&confirm);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Remove Computer"))
|
||||
.heading(gettext("Remove Computer?"))
|
||||
// Translators: {name} is the name of the computer being removed.
|
||||
.body(gettext_f(
|
||||
"Remove “{name}” from this account?\n\nEverything it backed up to Proton Drive is deleted along with it. The files on that computer itself are not touched — but this cannot be undone from here.",
|
||||
@@ -852,7 +1054,8 @@ pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Confirm, then adopt another device as this machine's identity.
|
||||
/// Confirm, then continue another computer's backup on this machine (adopt its
|
||||
/// device).
|
||||
///
|
||||
/// Worth a confirmation rather than a plain click: adoption re-points this
|
||||
/// machine's syncing at another computer's device folder, and the folders
|
||||
@@ -862,20 +1065,22 @@ pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Use this computer's identity"))
|
||||
// Translators: {name} is the name of the other computer whose identity is adopted.
|
||||
.heading(gettext("Continue This Backup Here?"))
|
||||
// Translators: {name} is the name of the other computer whose backup this one takes over.
|
||||
.body(gettext_f(
|
||||
"Treat this machine as “{name}”?\n\nNew synced folders are created under that computer in Proton Drive, and this machine keeps that identity even if its hostname changes. Folders already synced here are not moved.\n\nUse “Restore folders” afterwards to bring back what “{name}” was syncing.",
|
||||
"This computer takes over the backup of “{name}”, for example after a reinstall.\n\nNew synced folders go under “{name}” in Proton Drive, even if this computer's name changes. Folders already synced here are not moved. Use “Restore to This Computer” afterwards to bring back what “{name}” was syncing.",
|
||||
&[("name", name)],
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("adopt", &gettext("Use identity"));
|
||||
dialog.add_response("adopt", &gettext("Continue Here"));
|
||||
dialog.set_response_appearance("adopt", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
let ui = ui.clone();
|
||||
let uid = uid.to_string();
|
||||
// Translators: {name} is the name of the computer whose backup this one took over.
|
||||
let done = gettext_f("Now backing up as “{name}”", &[("name", name)]);
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
if resp == "adopt" {
|
||||
run_devices_mutation(
|
||||
@@ -883,8 +1088,8 @@ pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
Request::AdoptDevice {
|
||||
uid: Some(uid.clone()),
|
||||
},
|
||||
&gettext("Identity adopted"),
|
||||
&gettext("Couldn't adopt that computer"),
|
||||
&done,
|
||||
&gettext("Couldn't continue that backup here"),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -909,7 +1114,11 @@ pub(crate) fn run_devices_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, &failed, &message, kind)
|
||||
}
|
||||
_ => toast_error(&ui, &failed, &gettext("The mount service didn't respond.")),
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
&failed,
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -933,20 +1142,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_machine_with_no_folders_is_told_where_to_add_one() {
|
||||
assert_eq!(
|
||||
this_computer_subtitle(&[]),
|
||||
"No folders backed up yet — add one on the Sync page."
|
||||
);
|
||||
fn a_machine_with_no_folders_says_so() {
|
||||
assert_eq!(this_computer_subtitle(&[]), "No folders backed up yet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folders_needing_attention_are_counted_not_hidden() {
|
||||
// The per-folder rows moved to Locations, so this row is the only place
|
||||
// the Computers page can surface that something is wrong.
|
||||
// The per-folder rows live on the Sync page, so this row is the only
|
||||
// place the Computers page can surface that something is wrong.
|
||||
assert_eq!(
|
||||
this_computer_subtitle(&[folder("idle"), folder("idle")]),
|
||||
"2 folders backed up · manage them on the Sync page"
|
||||
"2 folders backed up"
|
||||
);
|
||||
assert_eq!(
|
||||
this_computer_subtitle(&[folder("idle"), folder("error")]),
|
||||
@@ -964,4 +1170,28 @@ mod tests {
|
||||
assert!(restore_picker_body(None).contains("this computer"));
|
||||
assert!(nothing_to_restore(Some("laptop")).contains("“laptop”"));
|
||||
}
|
||||
|
||||
fn device(device_type: &str, last_sync: Option<i64>) -> DeviceInfo {
|
||||
DeviceInfo {
|
||||
uid: "dev".into(),
|
||||
name: "laptop".into(),
|
||||
device_type: device_type.into(),
|
||||
last_sync,
|
||||
this_device: false,
|
||||
adopted: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_computer_that_never_backed_up_says_no_backups_yet() {
|
||||
assert_eq!(
|
||||
device_subtitle(&device("Linux", None)),
|
||||
"Linux · no backups yet"
|
||||
);
|
||||
assert_eq!(
|
||||
device_subtitle(&device("Linux", Some(0))),
|
||||
"Linux · no backups yet"
|
||||
);
|
||||
assert!(device_subtitle(&device("MacOs", Some(1))).starts_with("macOS · last backup "));
|
||||
}
|
||||
}
|
||||
@@ -75,22 +75,19 @@ pub(crate) fn show_duplicates(ui: &Rc<Ui>) {
|
||||
bottom.set_halign(gtk4::Align::End);
|
||||
bottom.append(&trash);
|
||||
|
||||
let exact = gtk4::ToggleButton::builder()
|
||||
.label(pgettext("duplicates", "Exact"))
|
||||
.tooltip_text(gettext("Photos stored more than once"))
|
||||
.active(true)
|
||||
.build();
|
||||
let similar = gtk4::ToggleButton::builder()
|
||||
.label(pgettext("duplicates", "Similar"))
|
||||
.tooltip_text(gettext("Photos that look alike"))
|
||||
.group(&exact)
|
||||
.build();
|
||||
let switch = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
switch.add_css_class("linked");
|
||||
switch.append(&exact);
|
||||
switch.append(&similar);
|
||||
// Exact first, then Similar: the index is the finder.
|
||||
let switch = ToggleGroup::new(&[
|
||||
(
|
||||
&pgettext("duplicates", "Exact"),
|
||||
Some(&gettext("Photos stored more than once")),
|
||||
),
|
||||
(
|
||||
&pgettext("duplicates", "Similar"),
|
||||
Some(&gettext("Photos that look alike")),
|
||||
),
|
||||
]);
|
||||
let header = adw::HeaderBar::new();
|
||||
header.set_title_widget(Some(&switch));
|
||||
header.set_title_widget(Some(&switch.widget()));
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
@@ -124,8 +121,8 @@ pub(crate) fn show_duplicates(ui: &Rc<Ui>) {
|
||||
.set(view_closed.generation.get().wrapping_add(1));
|
||||
});
|
||||
let view_switch = view.clone();
|
||||
similar.connect_toggled(move |similar| {
|
||||
let finder = if similar.is_active() {
|
||||
switch.connect_changed(move |index| {
|
||||
let finder = if index == 1 {
|
||||
Finder::Similar
|
||||
} else {
|
||||
Finder::Exact
|
||||
@@ -220,14 +217,14 @@ fn ask(view: &Rc<FinderView>, finder: Finder, generation: u32) {
|
||||
view.status.set_icon_name(Some("dialog-warning-symbolic"));
|
||||
view.status.set_title(&failed);
|
||||
view.status
|
||||
.set_description(Some(&gettext("The mount service didn't respond.")));
|
||||
.set_description(Some(&gettext("The Proton Drive service didn't respond.")));
|
||||
view.progress.set_visible(false);
|
||||
return;
|
||||
}
|
||||
};
|
||||
view.progress.set_visible(false);
|
||||
if sets.is_empty() {
|
||||
view.status.set_icon_name(Some("emblem-ok-symbolic"));
|
||||
view.status.set_icon_name(Some("pdfs-synced-symbolic"));
|
||||
match finder {
|
||||
Finder::Exact => {
|
||||
view.status.set_title(&gettext("No duplicates"));
|
||||
|
||||
@@ -7,7 +7,7 @@ pub(crate) struct LoginState {
|
||||
pub(crate) password: adw::PasswordEntryRow,
|
||||
pub(crate) login_button: gtk4::Button,
|
||||
/// Spins inside the Sign in button while a sign-in is running.
|
||||
pub(crate) login_spinner: gtk4::Spinner,
|
||||
pub(crate) login_spinner: Spinner,
|
||||
pub(crate) login_status: gtk4::Label,
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@ pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) {
|
||||
group.add(&email);
|
||||
group.add(&password);
|
||||
|
||||
let login_spinner = gtk4::Spinner::builder().visible(false).build();
|
||||
let login_spinner = spinner();
|
||||
login_spinner.set_visible(false);
|
||||
let button_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
button_content.append(&login_spinner);
|
||||
button_content.append(>k4::Label::new(Some(&gettext("Sign in"))));
|
||||
@@ -170,7 +171,7 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
// Cache the new identity so `refresh` never hits the keyring.
|
||||
*ui.session.borrow_mut() = auth::load().ok();
|
||||
// Enable+start the mount service now that we have a session.
|
||||
service::enable_start();
|
||||
let _ = gio::spawn_blocking(service::enable_start).await;
|
||||
refresh(&ui);
|
||||
}
|
||||
Err(e) => ui.login.login_status.set_text(&e),
|
||||
@@ -185,7 +186,6 @@ fn set_signing_in(ui: &Rc<Ui>, running: bool) {
|
||||
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
|
||||
@@ -295,7 +295,7 @@ pub(crate) fn spawn_login(
|
||||
/// the worker reads as a cancelled login.
|
||||
pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Two-factor authentication"))
|
||||
.heading(gettext("Two-Factor Authentication"))
|
||||
.body(gettext(
|
||||
"Enter the code from your authenticator app, or one of your recovery codes.",
|
||||
))
|
||||
@@ -344,12 +344,20 @@ pub(crate) fn sign_out(ui: &Rc<Ui>) {
|
||||
),
|
||||
&gettext("Sign Out"),
|
||||
move || {
|
||||
service::disable_stop();
|
||||
if let Err(e) = auth::logout() {
|
||||
tracing::error!("logout failed: {e}");
|
||||
}
|
||||
*ui.session.borrow_mut() = None;
|
||||
refresh(&ui);
|
||||
// systemctl and the keyring both block, so run them on a worker.
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = gio::spawn_blocking(|| {
|
||||
service::disable_stop();
|
||||
auth::logout().map_err(|e| e.to_string())
|
||||
})
|
||||
.await;
|
||||
if let Ok(Err(e)) = result {
|
||||
tracing::error!("logout failed: {e}");
|
||||
}
|
||||
*ui.session.borrow_mut() = None;
|
||||
refresh(&ui);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub(crate) mod activity;
|
||||
pub(crate) mod activity_text;
|
||||
pub(crate) mod albums;
|
||||
pub(crate) mod browser;
|
||||
pub(crate) mod devices;
|
||||
|
||||
@@ -31,7 +31,7 @@ pub(crate) struct Viewer {
|
||||
pub(crate) scroller: gtk4::ScrolledWindow,
|
||||
pub(crate) picture: gtk4::Picture,
|
||||
pub(crate) video: gtk4::Video,
|
||||
pub(crate) spinner: gtk4::Spinner,
|
||||
pub(crate) spinner: Spinner,
|
||||
pub(crate) status: gtk4::Label,
|
||||
pub(crate) title: gtk4::Label,
|
||||
pub(crate) counter: gtk4::Label,
|
||||
@@ -224,7 +224,6 @@ fn viewer_item(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: &str) -> Option<PhotoItem>
|
||||
/// plays in place once its file is down.
|
||||
pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
viewer.spinner.set_visible(true);
|
||||
viewer.spinner.start();
|
||||
viewer.status.set_visible(false);
|
||||
viewer.loading.set(true);
|
||||
*viewer.path.borrow_mut() = None;
|
||||
@@ -266,7 +265,6 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
if *viewer.uid.borrow() != uid {
|
||||
return;
|
||||
}
|
||||
viewer.spinner.stop();
|
||||
viewer.spinner.set_visible(false);
|
||||
viewer.loading.set(false);
|
||||
// Armed whether or not the photo rendered: a slideshow steps past a
|
||||
@@ -307,7 +305,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
}
|
||||
},
|
||||
Ok(Ok(Response::Error { message, .. })) => fail(&message),
|
||||
Ok(Ok(_)) => fail(&gettext("Unexpected reply from the mount service.")),
|
||||
Ok(Ok(_)) => fail(&gettext("Unexpected reply from the Proton Drive service.")),
|
||||
Ok(Err(_)) | Err(_) => fail(&gettext("Couldn't reach Proton Drive.")),
|
||||
}
|
||||
});
|
||||
@@ -1136,7 +1134,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
.child(
|
||||
&adw::ButtonContent::builder()
|
||||
.label(gettext("Show on map"))
|
||||
.icon_name("map-symbolic")
|
||||
.icon_name("pdfs-map-symbolic")
|
||||
.build(),
|
||||
)
|
||||
.halign(gtk4::Align::Start)
|
||||
@@ -1192,12 +1190,10 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
.child(&info_panel)
|
||||
.build();
|
||||
|
||||
let spinner = gtk4::Spinner::builder()
|
||||
.halign(gtk4::Align::Center)
|
||||
.valign(gtk4::Align::Center)
|
||||
.width_request(48)
|
||||
.height_request(48)
|
||||
.build();
|
||||
let spinner = spinner();
|
||||
spinner.set_halign(gtk4::Align::Center);
|
||||
spinner.set_valign(gtk4::Align::Center);
|
||||
spinner.set_size_request(48, 48);
|
||||
spinner.add_css_class("viewer-spinner");
|
||||
overlay.add_overlay(&spinner);
|
||||
|
||||
@@ -1426,7 +1422,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
None
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) => Some(message),
|
||||
_ => Some(gettext("The mount service didn't respond.")),
|
||||
_ => Some(gettext("The Proton Drive service didn't respond.")),
|
||||
};
|
||||
if let Some(detail) = failed {
|
||||
toast_error(
|
||||
|
||||
@@ -102,7 +102,7 @@ pub(crate) fn load_places(ui: &Rc<Ui>) {
|
||||
&ui,
|
||||
"network-offline-symbolic",
|
||||
&gettext("Not connected"),
|
||||
&gettext("The Proton Drive mount service didn't respond."),
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -422,7 +422,7 @@ fn open_place(ui: &Rc<Ui>, place: PlaceInfo) {
|
||||
|
||||
// As in an album: no filters, no Upload, no view switch — back leads out.
|
||||
ui.gallery.filters.set_visible(false);
|
||||
ui.gallery.view_switch.set_visible(false);
|
||||
ui.gallery.view_switch.widget().set_visible(false);
|
||||
ui.gallery.upload.set_visible(false);
|
||||
ui.gallery.back.set_visible(true);
|
||||
ui.gallery.kind.set(None);
|
||||
@@ -439,7 +439,7 @@ pub(crate) fn close_place(ui: &Rc<Ui>) {
|
||||
return;
|
||||
}
|
||||
ui.gallery.title.set_title(&gettext("Photos"));
|
||||
ui.gallery.view_switch.set_visible(true);
|
||||
ui.gallery.view_switch.widget().set_visible(true);
|
||||
ui.gallery.upload.set_visible(true);
|
||||
ui.gallery.back.set_visible(false);
|
||||
}
|
||||
@@ -455,14 +455,14 @@ fn places_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) {
|
||||
/// Wire the Places toggle of the view switcher.
|
||||
pub(crate) fn wire_places(ui: &Rc<Ui>) {
|
||||
let ui_places = ui.clone();
|
||||
ui.gallery.places_btn.clone().connect_toggled(move |btn| {
|
||||
if btn.is_active() {
|
||||
ui.gallery.view_switch.connect_changed(move |view| {
|
||||
if view == VIEW_PLACES {
|
||||
show_places(&ui_places);
|
||||
}
|
||||
});
|
||||
|
||||
let ui_layout = ui.clone();
|
||||
ui.gallery.places_map_btn.connect_toggled(move |_| {
|
||||
ui.gallery.places_layout.connect_changed(move |_| {
|
||||
// A status page (loading, empty, an error) stays until a listing
|
||||
// replaces it; only the grid and the map swap.
|
||||
if ui_layout
|
||||
@@ -493,19 +493,19 @@ pub(crate) fn wire_places(ui: &Rc<Ui>) {
|
||||
/// Switch to the Places map and zoom in on `(latitude, longitude)`, where a
|
||||
/// photo in the viewer was taken.
|
||||
pub(crate) fn show_on_map(ui: &Rc<Ui>, latitude: f64, longitude: f64) {
|
||||
ui.gallery.places_map_btn.set_active(true);
|
||||
if ui.gallery.places_btn.is_active() {
|
||||
ui.gallery.places_layout.set_active(PLACES_MAP);
|
||||
if ui.gallery.view_switch.active() == VIEW_PLACES {
|
||||
// Already in Places, perhaps inside a town: back out to the listing.
|
||||
show_places(ui);
|
||||
} else {
|
||||
ui.gallery.places_btn.set_active(true);
|
||||
ui.gallery.view_switch.set_active(VIEW_PLACES);
|
||||
}
|
||||
ui.gallery.places_map.show_spot(latitude, longitude);
|
||||
}
|
||||
|
||||
/// Show the places as cards or on the map, as the Grid/Map toggle says.
|
||||
fn show_places_layout(ui: &Rc<Ui>) {
|
||||
let layout = if ui.gallery.places_map_btn.is_active() {
|
||||
let layout = if ui.gallery.places_layout.active() == PLACES_MAP {
|
||||
"map"
|
||||
} else {
|
||||
"grid"
|
||||
|
||||
@@ -1,38 +1,94 @@
|
||||
use crate::*;
|
||||
|
||||
pub(crate) struct SharedByMeState {
|
||||
// Shared (by me) page: one section listing the items I've shared, each with a
|
||||
// copy-link / manage affordance. Rebuilt wholesale on each load.
|
||||
// Shared by me page: the items I have shared, as a file list whose columns
|
||||
// say where each lives, who has it and what its public link is doing.
|
||||
pub(crate) files: FileList,
|
||||
/// "list", or "status" while the page loads, is empty or can't be read.
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
||||
pub(crate) inflight: Cell<bool>,
|
||||
pub(crate) loaded_at: Cell<Option<Instant>>,
|
||||
/// "12 items" under the page title.
|
||||
pub(crate) subtitle: adw::WindowTitle,
|
||||
/// Which items to show, indexed like [`SHARE_FILTERS`].
|
||||
pub(crate) filter: gtk4::DropDown,
|
||||
/// The last listing, unfiltered. The list holds [`DirEntry`]s; the columns
|
||||
/// and menus look a row's people and link up here by uid.
|
||||
pub(crate) items: Rc<RefCell<Vec<SharedItem>>>,
|
||||
/// Runs the loads; the rows stay up while one runs.
|
||||
pub(crate) loader: Rc<Loader>,
|
||||
}
|
||||
|
||||
impl SharedByMeState {
|
||||
pub(crate) fn new(widgets: &SharedByMeWidgets) -> Self {
|
||||
SharedByMeState {
|
||||
files: widgets.files.clone(),
|
||||
content: widgets.content.clone(),
|
||||
status: widgets.status.clone(),
|
||||
retry: widgets.retry.clone(),
|
||||
subtitle: widgets.subtitle.clone(),
|
||||
filter: widgets.filter.clone(),
|
||||
items: Rc::new(RefCell::new(Vec::new())),
|
||||
loader: Loader::new(&widgets.content),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Widgets the Shared (by-me) page's load/repaint touch.
|
||||
pub(crate) struct SharedByMeWidgets {
|
||||
pub(crate) files: FileList,
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) refresh: gtk4::Button,
|
||||
pub(crate) subtitle: adw::WindowTitle,
|
||||
pub(crate) filter: gtk4::DropDown,
|
||||
}
|
||||
|
||||
/// The Shared page: one section listing the items I have shared with others —
|
||||
/// each row summarizing who has access, opening the item when activated, with
|
||||
/// Copy link and a menu for the link and the per-node Share dialog.
|
||||
pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
|
||||
let refresh = refresh_button();
|
||||
/// Which shared items the page shows.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) enum ShareFilter {
|
||||
All,
|
||||
/// Items with a public link.
|
||||
Links,
|
||||
/// Items shared with people, invited or already in.
|
||||
People,
|
||||
/// Items whose public link has expired.
|
||||
Expired,
|
||||
}
|
||||
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let clamp = adw::Clamp::builder().child(&group).build();
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
/// The filters in the order the header's drop-down lists them.
|
||||
const SHARE_FILTERS: [ShareFilter; 4] = [
|
||||
ShareFilter::All,
|
||||
ShareFilter::Links,
|
||||
ShareFilter::People,
|
||||
ShareFilter::Expired,
|
||||
];
|
||||
|
||||
fn share_filter_label(filter: ShareFilter) -> String {
|
||||
match filter {
|
||||
ShareFilter::All => gettext("All Shared Items"),
|
||||
ShareFilter::Links => gettext("Links Only"),
|
||||
ShareFilter::People => gettext("People Only"),
|
||||
ShareFilter::Expired => gettext("Expired Links"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Shared by me page: every item I have shared with people or by link.
|
||||
/// Activating one opens its Share dialog, since managing access is what this
|
||||
/// page is for; opening the item itself is in the menu.
|
||||
pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
|
||||
let files = FileList::new();
|
||||
files.show_list(true);
|
||||
let layout = layout_button(&files);
|
||||
let labels: Vec<String> = SHARE_FILTERS
|
||||
.iter()
|
||||
.copied()
|
||||
.map(share_filter_label)
|
||||
.collect();
|
||||
let filter =
|
||||
gtk4::DropDown::from_strings(&labels.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
filter.set_valign(gtk4::Align::Center);
|
||||
filter.set_tooltip_text(Some(&gettext("Show only some shared items")));
|
||||
|
||||
let retry = gtk4::Button::builder()
|
||||
.label(gettext("Retry"))
|
||||
@@ -42,7 +98,7 @@ pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
|
||||
retry.add_css_class("suggested-action");
|
||||
retry.set_visible(false);
|
||||
let status = adw::StatusPage::builder()
|
||||
.icon_name("emblem-shared-symbolic")
|
||||
.icon_name("pdfs-share-symbolic")
|
||||
.vexpand(true)
|
||||
.child(&retry)
|
||||
.build();
|
||||
@@ -51,277 +107,154 @@ pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
|
||||
let content = gtk4::Stack::new();
|
||||
content.set_vexpand(true);
|
||||
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
||||
content.add_named(&scroll, Some("list"));
|
||||
content.add_named(&files.views, Some("list"));
|
||||
content.add_named(&status, Some("status"));
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
inner.set_margin_top(18);
|
||||
inner.set_margin_bottom(18);
|
||||
inner.set_margin_start(18);
|
||||
inner.set_margin_end(18);
|
||||
inner.set_margin_top(12);
|
||||
inner.set_margin_bottom(12);
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&content);
|
||||
let (frame, header, _) = page_frame(&gettext("Shared by Me"), &inner);
|
||||
header.pack_end(&refresh);
|
||||
|
||||
let (frame, header, subtitle) = page_frame(&gettext("Shared by me"), &inner);
|
||||
header.pack_start(&filter);
|
||||
header.pack_end(&layout);
|
||||
|
||||
(
|
||||
frame.upcast(),
|
||||
SharedByMeWidgets {
|
||||
files,
|
||||
content,
|
||||
status,
|
||||
group,
|
||||
retry,
|
||||
refresh,
|
||||
subtitle,
|
||||
filter,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Install the Shared (by-me) page's retry button.
|
||||
pub(crate) fn wire_shared_by_me(ui: &Rc<Ui>, retry: >k4::Button) {
|
||||
/// Install the file list, its columns, the filter and the retry button.
|
||||
pub(crate) fn wire_shared_by_me(ui: &Rc<Ui>, widgets: &SharedByMeWidgets) {
|
||||
let files = &ui.shared_by_me.files;
|
||||
files.wire(
|
||||
ui,
|
||||
FileListBehavior {
|
||||
activate: open_share_dialog,
|
||||
entry_menu: shared_by_me_menu,
|
||||
bulk_menu: shared_by_me_bulk_menu,
|
||||
background_menu: shared_by_me_background_menu,
|
||||
badges: false,
|
||||
drag_and_drop: false,
|
||||
},
|
||||
);
|
||||
let items = &ui.shared_by_me.items;
|
||||
files
|
||||
.column_view
|
||||
.append_column(&text_column(&gettext("Location"), |e| {
|
||||
location_label(&e.path)
|
||||
}));
|
||||
let people = items.clone();
|
||||
files
|
||||
.column_view
|
||||
.append_column(&text_column(&pgettext("column", "People"), move |e| {
|
||||
find_item(&people, &e.uid)
|
||||
.map(|item| people_label(&item))
|
||||
.unwrap_or_else(|| "—".to_string())
|
||||
}));
|
||||
files.column_view.append_column(&link_column(ui));
|
||||
|
||||
let ui_retry = ui.clone();
|
||||
retry.connect_clicked(move |_| {
|
||||
service::restart();
|
||||
load_shared_by_me(&ui_retry);
|
||||
widgets
|
||||
.retry
|
||||
.connect_clicked(move |_| restart_service_then(&ui_retry, load_shared_by_me));
|
||||
let ui_filter = ui.clone();
|
||||
widgets.filter.connect_selected_notify(move |_| {
|
||||
let items = ui_filter.shared_by_me.items.borrow().clone();
|
||||
repaint_shared_by_me(&ui_filter, &items);
|
||||
});
|
||||
}
|
||||
|
||||
/// Show a status page in place of the Shared (by-me) list.
|
||||
pub(crate) fn shared_by_me_status(
|
||||
ui: &Rc<Ui>,
|
||||
icon: &str,
|
||||
title: &str,
|
||||
description: &str,
|
||||
retry: bool,
|
||||
) {
|
||||
ui.shared_by_me.status.set_icon_name(Some(icon));
|
||||
ui.shared_by_me.status.set_title(title);
|
||||
ui.shared_by_me.status.set_description(Some(description));
|
||||
ui.shared_by_me.retry.set_visible(retry);
|
||||
ui.shared_by_me.content.set_visible_child_name("status");
|
||||
/// The shared item a row stands for.
|
||||
fn find_item(items: &RefCell<Vec<SharedItem>>, uid: &str) -> Option<SharedItem> {
|
||||
items.borrow().iter().find(|item| item.uid == uid).cloned()
|
||||
}
|
||||
|
||||
/// Fetch the shared-by-me listing and repaint the page.
|
||||
pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
|
||||
if ui.shared_by_me.inflight.get() {
|
||||
return;
|
||||
}
|
||||
cancel_file_thumbnails(ui);
|
||||
ui.shared_by_me.inflight.set(true);
|
||||
shared_by_me_status(
|
||||
ui,
|
||||
"emblem-shared-symbolic",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading what you've shared."),
|
||||
false,
|
||||
);
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSharedByMe);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
ui.shared_by_me.inflight.set(false);
|
||||
match result {
|
||||
Ok(Ok(Response::SharedByMe { items })) => {
|
||||
repaint_shared_by_me(&ui, &items);
|
||||
ui.shared_by_me.loaded_at.set(Some(Instant::now()));
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) => shared_by_me_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
&gettext("Couldn't read your shares"),
|
||||
&message,
|
||||
false,
|
||||
),
|
||||
Ok(Ok(_)) => shared_by_me_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
&gettext("Couldn't read your shares"),
|
||||
&gettext("Unexpected reply from the mount service."),
|
||||
false,
|
||||
),
|
||||
Ok(Err(_)) | Err(_) => {
|
||||
ui.shared_by_me.loaded_at.set(None);
|
||||
shared_by_me_unreachable(&ui);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The daemon didn't answer the Shared (by-me) page.
|
||||
pub(crate) fn shared_by_me_unreachable(ui: &Rc<Ui>) {
|
||||
if service::is_failed() || !service::is_active() {
|
||||
shared_by_me_status(
|
||||
ui,
|
||||
"network-offline-symbolic",
|
||||
&gettext("Not connected"),
|
||||
&gettext("The Proton Drive mount service isn't running."),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
shared_by_me_status(
|
||||
ui,
|
||||
"folder-remote-symbolic",
|
||||
&gettext("Connecting…"),
|
||||
&gettext("Waiting for the Proton Drive mount service to come up."),
|
||||
false,
|
||||
);
|
||||
let ui = ui.clone();
|
||||
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
|
||||
if ui.stack.visible_child_name().as_deref() == Some("sharedbyme") {
|
||||
load_shared_by_me(&ui);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Rebuild the Shared (by-me) section from a fresh listing.
|
||||
pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
||||
for row in ui.shared_by_me.rows.borrow_mut().drain(..) {
|
||||
ui.shared_by_me.group.remove(&row);
|
||||
}
|
||||
if items.is_empty() {
|
||||
shared_by_me_status(
|
||||
ui,
|
||||
"emblem-shared-symbolic",
|
||||
&gettext("Nothing shared yet"),
|
||||
&gettext("Items you share with people or by link show up here."),
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
ui.shared_by_me.content.set_visible_child_name("list");
|
||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||
for item in items {
|
||||
let entry = shared_item_as_entry(item);
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&item.name)
|
||||
.subtitle(shared_item_summary(item))
|
||||
/// The Link column: what the public link is doing, and a button that copies
|
||||
/// it while it works.
|
||||
fn link_column(ui: &Rc<Ui>) -> gtk4::ColumnViewColumn {
|
||||
let factory = gtk4::SignalListItemFactory::new();
|
||||
let ui_setup = ui.clone();
|
||||
factory.connect_setup(move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let label = gtk4::Label::builder()
|
||||
.halign(gtk4::Align::Start)
|
||||
.hexpand(true)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
row.add_prefix(&file_thumbnail(ui, &entry, 40, 24, true));
|
||||
|
||||
// A node the daemon can place in my tree opens like it would in My Files.
|
||||
if !item.path.is_empty() {
|
||||
row.set_activatable(true);
|
||||
let ui_open = ui.clone();
|
||||
let entry_open = entry.clone();
|
||||
row.connect_activated(move |_| open_shared_by_me(&ui_open, &entry_open));
|
||||
}
|
||||
|
||||
let url = item.link.as_ref().and_then(|l| l.url.clone());
|
||||
// The one action people come here for most gets its own button.
|
||||
if let Some(url) = url.clone() {
|
||||
let copy = gtk4::Button::builder()
|
||||
.icon_name("edit-copy-symbolic")
|
||||
.tooltip_text(gettext("Copy link"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
copy.add_css_class("flat");
|
||||
let ui_copy = ui.clone();
|
||||
copy.connect_clicked(move |btn| {
|
||||
label.add_css_class("dim-label");
|
||||
let copy = gtk4::Button::builder()
|
||||
.icon_name("pdfs-link-symbolic")
|
||||
.tooltip_text(gettext("Copy link"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
copy.add_css_class("flat");
|
||||
// Read at click time: the view recycles this cell for other rows.
|
||||
let (ui, item_c) = (ui_setup.clone(), item.clone());
|
||||
copy.connect_clicked(move |btn| {
|
||||
let Some(obj) = item_c.item().and_downcast::<BoxedAnyObject>() else {
|
||||
return;
|
||||
};
|
||||
let uid = obj.borrow::<DirEntry>().uid.clone();
|
||||
if let Some(url) = find_item(&ui.shared_by_me.items, &uid)
|
||||
.and_then(|item| item.link)
|
||||
.and_then(|link| link.url)
|
||||
{
|
||||
btn.clipboard().set_text(&url);
|
||||
toast(&ui_copy, &gettext("Link copied"));
|
||||
});
|
||||
row.add_suffix(©);
|
||||
}
|
||||
row.add_suffix(&shared_by_me_menu(ui, &entry, url));
|
||||
|
||||
ui.shared_by_me.group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
}
|
||||
*ui.shared_by_me.rows.borrow_mut() = rows;
|
||||
}
|
||||
|
||||
/// The ⋮ menu on a shared item: open it, the link, the Share dialog, and
|
||||
/// Stop Sharing.
|
||||
///
|
||||
/// Manage access opens the per-node Share dialog, which addresses a pathless
|
||||
/// node by uid, so every shared item can be managed from here.
|
||||
fn shared_by_me_menu(ui: &Rc<Ui>, entry: &DirEntry, url: Option<String>) -> gtk4::MenuButton {
|
||||
let mut items: Vec<(String, MenuAction)> = Vec::new();
|
||||
if !entry.path.is_empty() {
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
items.push((
|
||||
pgettext("verb", "Open"),
|
||||
Box::new(move || open_shared_by_me(&ui_c, &entry_c)),
|
||||
));
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
items.push((
|
||||
gettext("Show in My Files"),
|
||||
Box::new(move || show_in_my_files(&ui_c, &entry_c)),
|
||||
));
|
||||
}
|
||||
if let Some(url) = url {
|
||||
items.push((gettext("Open Link"), Box::new(move || open_uri(&url))));
|
||||
}
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
items.push((
|
||||
gettext("Manage Access…"),
|
||||
Box::new(move || open_share_dialog(&ui_c, &entry_c)),
|
||||
));
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
items.push((
|
||||
gettext("Stop Sharing…"),
|
||||
Box::new(move || prompt_stop_sharing(&ui_c, &entry_c)),
|
||||
));
|
||||
let (labels, actions): (Vec<String>, Vec<MenuAction>) = items.into_iter().unzip();
|
||||
more_menu_button(labels.iter().map(String::as_str).zip(actions).collect())
|
||||
}
|
||||
|
||||
/// Confirm, then remove every member, invitation and the public link.
|
||||
pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Stop sharing?"))
|
||||
// Translators: {name} is the shared item's name.
|
||||
.body(gettext_f(
|
||||
"Everyone you invited loses access to “{name}”, pending invitations are withdrawn and its public link stops working.",
|
||||
&[("name", &entry.name)],
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("stop", &gettext("Stop Sharing"));
|
||||
dialog.set_response_appearance("stop", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
let ui_c = ui.clone();
|
||||
let uid = entry.uid.clone();
|
||||
let name = entry.name.clone();
|
||||
dialog.connect_response(Some("stop"), move |_, _| {
|
||||
run_mutation(
|
||||
&ui_c,
|
||||
Request::StopSharingByUid { uid: uid.clone() },
|
||||
// Translators: {name} is the item's name.
|
||||
gettext_f("Stopped sharing {name}", &[("name", &name)]),
|
||||
gettext_noop("Couldn't stop sharing"),
|
||||
flash_copied(btn, "pdfs-link-symbolic");
|
||||
toast(&ui, &gettext("Link copied"));
|
||||
}
|
||||
});
|
||||
let cell = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
|
||||
cell.append(&label);
|
||||
cell.append(©);
|
||||
item.set_child(Some(&cell));
|
||||
});
|
||||
let items = ui.shared_by_me.items.clone();
|
||||
factory.connect_bind(move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let cell = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||
let label = cell.first_child().and_downcast::<gtk4::Label>().unwrap();
|
||||
let copy = cell.last_child().unwrap();
|
||||
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
||||
let uid = obj.borrow::<DirEntry>().uid.clone();
|
||||
let link = find_item(&items, &uid).and_then(|item| item.link);
|
||||
let now = glib::real_time() / 1_000_000;
|
||||
let text = link_label(link.as_ref(), now);
|
||||
label.set_label(&text);
|
||||
label.set_tooltip_text(Some(&text));
|
||||
copy.set_visible(
|
||||
link.as_ref()
|
||||
.is_some_and(|l| l.url.is_some() && !link_expired(l, now)),
|
||||
);
|
||||
});
|
||||
dialog.present(ui_window(ui).as_ref());
|
||||
let column = gtk4::ColumnViewColumn::new(Some(&pgettext("column", "Link")), Some(factory));
|
||||
column.set_resizable(true);
|
||||
column
|
||||
}
|
||||
|
||||
/// 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);
|
||||
/// Where a shared item lives in My files: its folder, "My files" at the top,
|
||||
/// or a dash when the daemon can't place it.
|
||||
pub(crate) fn location_label(path: &str) -> String {
|
||||
match path.rsplit_once('/') {
|
||||
Some((parent, _)) => parent.to_string(),
|
||||
None if path.is_empty() => "—".to_string(),
|
||||
None => gettext("My files"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Go to the folder holding a shared item in My Files.
|
||||
pub(crate) fn show_in_my_files(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let parent = entry
|
||||
.path
|
||||
.rsplit_once('/')
|
||||
.map(|(parent, _)| parent.to_string())
|
||||
.unwrap_or_default();
|
||||
ui.stack.set_visible_child_name("browser");
|
||||
ui.browser.search.set_text("");
|
||||
browse_to(ui, parent);
|
||||
}
|
||||
|
||||
/// A one-line summary of who can reach a shared item, for its row subtitle.
|
||||
pub(crate) fn shared_item_summary(item: &SharedItem) -> String {
|
||||
/// Who can reach a shared item through an invitation.
|
||||
pub(crate) fn people_label(item: &SharedItem) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if item.member_count > 0 {
|
||||
// Translators: how many people have access to a shared item.
|
||||
@@ -341,19 +274,333 @@ pub(crate) fn shared_item_summary(item: &SharedItem) -> String {
|
||||
&[],
|
||||
));
|
||||
}
|
||||
if item.link.is_some() {
|
||||
parts.push(gettext("Public link"));
|
||||
}
|
||||
if parts.is_empty() {
|
||||
// Translators: a shared item's summary when no people or link can be counted.
|
||||
pgettext("state", "Shared")
|
||||
"—".to_string()
|
||||
} else {
|
||||
parts.join(" · ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a [`DirEntry`] from a [`SharedItem`] so the path-keyed Share dialog can
|
||||
/// open on it. Only used when the item's path is known.
|
||||
fn link_expired(link: &PublicLinkInfo, now: i64) -> bool {
|
||||
link.expires.is_some_and(|at| at <= now)
|
||||
}
|
||||
|
||||
/// What a public link is doing at `now` (epoch seconds): working, working
|
||||
/// until a date, or expired, and whether a password guards it.
|
||||
pub(crate) fn link_label(link: Option<&PublicLinkInfo>, now: i64) -> String {
|
||||
let Some(link) = link else {
|
||||
return "—".to_string();
|
||||
};
|
||||
let state = match link.expires {
|
||||
Some(at) if at <= now => pgettext("link", "Expired"),
|
||||
// Translators: {date} is when a public link stops working.
|
||||
Some(at) => gettext_f("Expires {date}", &[("date", &dates::short_date(at))]),
|
||||
None if link.created > 0 => gettext_f(
|
||||
// Translators: {date} is when the public link was created.
|
||||
"Active since {date}",
|
||||
&[("date", &dates::short_date(link.created))],
|
||||
),
|
||||
None => pgettext("link", "Active"),
|
||||
};
|
||||
if link.has_password {
|
||||
[state, gettext("Password protected")].join(" · ")
|
||||
} else {
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `item` passes `filter` at `now` (epoch seconds).
|
||||
pub(crate) fn matches_filter(item: &SharedItem, filter: ShareFilter, now: i64) -> bool {
|
||||
match filter {
|
||||
ShareFilter::All => true,
|
||||
ShareFilter::Links => item.link.is_some(),
|
||||
ShareFilter::People => item.member_count + item.invite_count > 0,
|
||||
ShareFilter::Expired => item.link.as_ref().is_some_and(|l| link_expired(l, now)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a status page in place of the Shared (by-me) list.
|
||||
pub(crate) fn shared_by_me_status(
|
||||
ui: &Rc<Ui>,
|
||||
icon: &str,
|
||||
title: &str,
|
||||
description: &str,
|
||||
retry: bool,
|
||||
) {
|
||||
ui.shared_by_me.status.set_icon_name(Some(icon));
|
||||
ui.shared_by_me.status.set_title(title);
|
||||
ui.shared_by_me.status.set_description(Some(description));
|
||||
ui.shared_by_me.retry.set_visible(retry);
|
||||
ui.shared_by_me.content.set_visible_child_name("status");
|
||||
ui.shared_by_me.subtitle.set_subtitle("");
|
||||
}
|
||||
|
||||
/// Fetch the shared-by-me listing and repaint the page.
|
||||
pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
|
||||
cancel_file_thumbnails(ui);
|
||||
let ui_p = ui.clone();
|
||||
let ticket = ui.shared_by_me.loader.refresh(move || {
|
||||
ui_p.shared_by_me.files.model.remove_all();
|
||||
shared_by_me_status(
|
||||
&ui_p,
|
||||
"pdfs-share-symbolic",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading what you've shared."),
|
||||
false,
|
||||
);
|
||||
});
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSharedByMe);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
if !ticket.is_current() {
|
||||
return;
|
||||
}
|
||||
if !matches!(result, Ok(Ok(Response::SharedByMe { .. }))) {
|
||||
ui.shared_by_me.files.model.remove_all();
|
||||
}
|
||||
match result {
|
||||
Ok(Ok(Response::SharedByMe { items })) => {
|
||||
let changed = changed_items(&ui.shared_by_me.items.borrow(), &items);
|
||||
*ui.shared_by_me.items.borrow_mut() = items.clone();
|
||||
repaint_shared_by_me(&ui, &items);
|
||||
rebind_rows(&ui, &changed);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) => shared_by_me_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
&gettext("Couldn't read your shares"),
|
||||
&message,
|
||||
false,
|
||||
),
|
||||
Ok(Ok(_)) => shared_by_me_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
&gettext("Couldn't read your shares"),
|
||||
&gettext("Unexpected reply from the Proton Drive service."),
|
||||
false,
|
||||
),
|
||||
Ok(Err(_)) | Err(_) => {
|
||||
shared_by_me_unreachable(&ui);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The daemon didn't answer the Shared (by-me) page.
|
||||
pub(crate) fn shared_by_me_unreachable(ui: &Rc<Ui>) {
|
||||
service_unreachable(ui, "sharedbyme", shared_by_me_status, load_shared_by_me);
|
||||
}
|
||||
|
||||
/// Show the items that pass the header's filter.
|
||||
pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
||||
if items.is_empty() {
|
||||
shared_by_me_status(
|
||||
ui,
|
||||
"pdfs-share-symbolic",
|
||||
&gettext("Nothing shared yet"),
|
||||
&gettext("Items you share with people or by link show up here."),
|
||||
false,
|
||||
);
|
||||
ui.shared_by_me.files.model.remove_all();
|
||||
return;
|
||||
}
|
||||
let filter = SHARE_FILTERS
|
||||
.get(ui.shared_by_me.filter.selected() as usize)
|
||||
.copied()
|
||||
.unwrap_or(ShareFilter::All);
|
||||
let now = glib::real_time() / 1_000_000;
|
||||
let shown: Vec<DirEntry> = items
|
||||
.iter()
|
||||
.filter(|item| matches_filter(item, filter, now))
|
||||
.map(shared_item_as_entry)
|
||||
.collect();
|
||||
if shown.is_empty() {
|
||||
shared_by_me_status(
|
||||
ui,
|
||||
"pdfs-share-symbolic",
|
||||
&gettext("No Matching Items"),
|
||||
&gettext("None of your shared items match this filter."),
|
||||
false,
|
||||
);
|
||||
ui.shared_by_me.files.model.remove_all();
|
||||
return;
|
||||
}
|
||||
ui.shared_by_me.content.set_visible_child_name("list");
|
||||
ui.shared_by_me.subtitle.set_subtitle(&ngettext_f(
|
||||
"{n} item",
|
||||
"{n} items",
|
||||
shown.len() as u64,
|
||||
&[],
|
||||
));
|
||||
// Only the rows that changed are swapped, so a refresh keeps the selection
|
||||
// and the scroll position.
|
||||
replace_items(&ui.shared_by_me.files.model, &shown);
|
||||
}
|
||||
|
||||
/// The uids in `new` whose people or link differ from `old`. Their rows show
|
||||
/// the same [`DirEntry`] as before, so the list would not repaint them.
|
||||
fn changed_items(old: &[SharedItem], new: &[SharedItem]) -> HashSet<String> {
|
||||
new.iter()
|
||||
.filter(|item| old.iter().find(|o| o.uid == item.uid) != Some(*item))
|
||||
.map(|item| item.uid.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Have the list bind the rows of `uids` again, to repaint their columns.
|
||||
fn rebind_rows(ui: &Rc<Ui>, uids: &HashSet<String>) {
|
||||
let model = &ui.shared_by_me.files.model;
|
||||
for pos in 0..model.n_items() {
|
||||
if entry_at(Some(model), pos).is_some_and(|e| uids.contains(&e.uid)) {
|
||||
model.items_changed(pos, 1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A shared item's right-click menu: manage it, open it, its link, and Stop
|
||||
/// Sharing. Manage Access opens the 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) -> ActionMenu {
|
||||
let mut menu = ActionMenu::new();
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
menu.item(&gettext("Manage Access…"), move || {
|
||||
open_share_dialog(&ui_c, &entry_c)
|
||||
});
|
||||
// A node the daemon can place in my tree opens like it would in My Files.
|
||||
if !entry.path.is_empty() {
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
menu.item(&pgettext("verb", "Open"), move || {
|
||||
open_shared_by_me(&ui_c, &entry_c)
|
||||
});
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
menu.item(&gettext("Show in My Files"), move || {
|
||||
show_in_my_files(&ui_c, &entry_c)
|
||||
});
|
||||
}
|
||||
let now = glib::real_time() / 1_000_000;
|
||||
let url = find_item(&ui.shared_by_me.items, &entry.uid)
|
||||
.and_then(|item| item.link)
|
||||
.filter(|link| !link_expired(link, now))
|
||||
.and_then(|link| link.url);
|
||||
if let Some(url) = url {
|
||||
menu.section();
|
||||
let (ui_c, url_c) = (ui.clone(), url.clone());
|
||||
menu.item(&gettext("Copy Link"), move || {
|
||||
ui_c.stack.clipboard().set_text(&url_c);
|
||||
toast(&ui_c, &gettext("Link copied"));
|
||||
});
|
||||
menu.item(&gettext("Open Link"), move || open_uri(&url));
|
||||
}
|
||||
menu.section();
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
menu.item(&gettext("Stop Sharing…"), move || {
|
||||
prompt_stop_sharing(&ui_c, std::slice::from_ref(&entry_c))
|
||||
});
|
||||
menu
|
||||
}
|
||||
|
||||
fn shared_by_me_bulk_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu {
|
||||
let mut menu = ActionMenu::new();
|
||||
menu.labelled_section(&ngettext_f(
|
||||
"{n} selected",
|
||||
"{n} selected",
|
||||
entries.len() as u64,
|
||||
&[],
|
||||
));
|
||||
let ui_c = ui.clone();
|
||||
menu.item(&gettext("Stop Sharing…"), move || {
|
||||
prompt_stop_sharing(&ui_c, &entries)
|
||||
});
|
||||
menu
|
||||
}
|
||||
|
||||
fn shared_by_me_background_menu(ui: &Rc<Ui>) -> ActionMenu {
|
||||
let mut menu = ActionMenu::new();
|
||||
let ui_c = ui.clone();
|
||||
menu.item(&gettext("Select All"), move || {
|
||||
ui_c.shared_by_me.files.selection.select_all();
|
||||
});
|
||||
let ui_c = ui.clone();
|
||||
menu.item(&gettext("Refresh"), move || reload_current_page(&ui_c));
|
||||
menu
|
||||
}
|
||||
|
||||
/// Confirm, then remove every member, invitation and the public link.
|
||||
pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
let body = match entries {
|
||||
// Translators: {name} is the shared item's name.
|
||||
[one] => gettext_f(
|
||||
"Everyone you invited loses access to “{name}”, pending invitations are withdrawn and its public link stops working.",
|
||||
&[("name", &one.name)],
|
||||
),
|
||||
_ => ngettext_f(
|
||||
"Everyone you invited loses access to {n} item, pending invitations are withdrawn and its public link stops working.",
|
||||
"Everyone you invited loses access to {n} items, pending invitations are withdrawn and their public links stop working.",
|
||||
entries.len() as u64,
|
||||
&[],
|
||||
),
|
||||
};
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Stop Sharing?"))
|
||||
.body(body)
|
||||
.build();
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("stop", &gettext("Stop Sharing"));
|
||||
dialog.set_response_appearance("stop", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
let ui_c = ui.clone();
|
||||
let done = match entries {
|
||||
// Translators: {name} is the item's name.
|
||||
[one] => gettext_f("Stopped sharing {name}", &[("name", &one.name)]),
|
||||
_ => ngettext_f(
|
||||
"Stopped sharing {n} item",
|
||||
"Stopped sharing {n} items",
|
||||
entries.len() as u64,
|
||||
&[],
|
||||
),
|
||||
};
|
||||
let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect();
|
||||
dialog.connect_response(Some("stop"), move |_, _| {
|
||||
let done = done.clone();
|
||||
run_requests(
|
||||
&ui_c,
|
||||
uids.iter()
|
||||
.map(|uid| Request::StopSharingByUid { uid: uid.clone() })
|
||||
.collect(),
|
||||
gettext_noop("Couldn't stop sharing"),
|
||||
move |ui| {
|
||||
reload_listing(ui);
|
||||
toast(ui, &done);
|
||||
},
|
||||
);
|
||||
});
|
||||
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 {
|
||||
open_in_my_files(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();
|
||||
open_in_my_files(ui, parent);
|
||||
}
|
||||
|
||||
/// Build a [`DirEntry`] from a [`SharedItem`], for the file list and the
|
||||
/// Share dialog, which addresses a pathless node by uid.
|
||||
pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
||||
DirEntry {
|
||||
name: item.name.clone(),
|
||||
@@ -369,5 +616,80 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
||||
shared_by: String::new(),
|
||||
shared_at: 0,
|
||||
shared_by_unverified: false,
|
||||
trashed_at: 0,
|
||||
trashed_from: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn item(members: usize, invites: usize, link: Option<PublicLinkInfo>) -> SharedItem {
|
||||
SharedItem {
|
||||
uid: "vol~link".into(),
|
||||
name: "Budget.ods".into(),
|
||||
is_dir: false,
|
||||
modified: 0,
|
||||
path: "Work/Budget.ods".into(),
|
||||
member_count: members,
|
||||
invite_count: invites,
|
||||
link,
|
||||
}
|
||||
}
|
||||
|
||||
fn link(expires: Option<i64>, has_password: bool) -> PublicLinkInfo {
|
||||
PublicLinkInfo {
|
||||
id: "l".into(),
|
||||
url: Some("https://drive.proton.me/urls/X#y".into()),
|
||||
role: "viewer".into(),
|
||||
expires,
|
||||
has_password,
|
||||
created: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_says_whether_it_still_works() {
|
||||
assert_eq!(link_label(None, 100), "—");
|
||||
assert_eq!(link_label(Some(&link(None, false)), 100), "Active");
|
||||
assert_eq!(link_label(Some(&link(Some(100), false)), 100), "Expired");
|
||||
assert!(link_label(Some(&link(Some(200), false)), 100).starts_with("Expires "));
|
||||
assert_eq!(
|
||||
link_label(Some(&link(None, true)), 100),
|
||||
"Active · Password protected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_filters_pick_links_people_and_expired_links() {
|
||||
let people = item(2, 0, None);
|
||||
let invited = item(0, 1, None);
|
||||
let live = item(0, 0, Some(link(None, false)));
|
||||
let expired = item(0, 0, Some(link(Some(50), false)));
|
||||
let pick = |filter| {
|
||||
[&people, &invited, &live, &expired]
|
||||
.iter()
|
||||
.map(|i| matches_filter(i, filter, 100))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(pick(ShareFilter::All), [true, true, true, true]);
|
||||
assert_eq!(pick(ShareFilter::Links), [false, false, true, true]);
|
||||
assert_eq!(pick(ShareFilter::People), [true, true, false, false]);
|
||||
assert_eq!(pick(ShareFilter::Expired), [false, false, false, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_location_is_the_folder_in_my_files() {
|
||||
assert_eq!(location_label("Work/Budget.ods"), "Work");
|
||||
assert_eq!(location_label("Budget.ods"), "My files");
|
||||
assert_eq!(location_label(""), "—");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn people_counts_members_and_pending_invitations() {
|
||||
assert_eq!(people_label(&item(0, 0, None)), "—");
|
||||
assert_eq!(people_label(&item(1, 0, None)), "1 person");
|
||||
assert_eq!(people_label(&item(2, 3, None)), "2 people · 3 pending");
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ pub(crate) struct StatusState {
|
||||
/// Sidebar footer: the one-line sync state. The mount is driven by the
|
||||
/// systemd user service, not by the user — so this only reports.
|
||||
pub(crate) status_icon: gtk4::Image,
|
||||
/// Stands in for [`Self::status_icon`] until the first status arrives.
|
||||
pub(crate) status_spinner: Spinner,
|
||||
pub(crate) status_title: gtk4::Label,
|
||||
pub(crate) status_detail: gtk4::Label,
|
||||
pub(crate) cache_bar: gtk4::ProgressBar,
|
||||
@@ -82,12 +84,29 @@ pub(crate) struct StatusState {
|
||||
/// How many transfers were in flight on the previous poll. A drop to zero is
|
||||
/// what "sync complete" means; there's no completion event on the wire.
|
||||
pub(crate) active_transfers: Cell<usize>,
|
||||
/// The run of transfers since the count last left zero, for deciding
|
||||
/// whether its end is worth a "Sync complete" notification.
|
||||
pub(crate) transfer_batch: RefCell<Option<TransferBatch>>,
|
||||
}
|
||||
|
||||
/// One run of transfers, from the first file to the count falling back to 0.
|
||||
pub(crate) struct TransferBatch {
|
||||
started: Instant,
|
||||
/// Every file seen in flight during the run, so the notification can count
|
||||
/// files rather than the handful still moving on the last poll.
|
||||
names: HashSet<String>,
|
||||
}
|
||||
|
||||
/// A run of transfers earns a "Sync complete" notification only when it moved
|
||||
/// at least this many files or lasted at least [`SYNC_NOTIFY_MIN_DURATION`].
|
||||
/// Saving one document at a time would otherwise notify on every save.
|
||||
const SYNC_NOTIFY_MIN_FILES: usize = 5;
|
||||
const SYNC_NOTIFY_MIN_DURATION: Duration = Duration::from_secs(30);
|
||||
|
||||
/// One rendered pin row, retained so [`repaint_pins`] can flip the unpin button's
|
||||
/// `sensitive` in place (when the pin set is unchanged) instead of rebuilding.
|
||||
pub(crate) struct PinRow {
|
||||
pub(crate) row: adw::ActionRow,
|
||||
pub(crate) row: gtk4::ListBoxRow,
|
||||
/// The unpin button, absent on the placeholder row.
|
||||
pub(crate) unpin: Option<gtk4::Button>,
|
||||
}
|
||||
@@ -121,6 +140,7 @@ pub(crate) struct MainWidgets {
|
||||
pub(crate) account_name: gtk4::Label,
|
||||
pub(crate) avatar: adw::Avatar,
|
||||
pub(crate) status_icon: gtk4::Image,
|
||||
pub(crate) status_spinner: Spinner,
|
||||
pub(crate) status_title: gtk4::Label,
|
||||
pub(crate) status_detail: gtk4::Label,
|
||||
pub(crate) quota_box: gtk4::Box,
|
||||
@@ -178,9 +198,7 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
||||
mountpoint_row.add_css_class("property");
|
||||
let mountpoint_button = gtk4::Button::builder()
|
||||
.label(gettext("Change…"))
|
||||
.tooltip_text(gettext(
|
||||
"Choose a different folder for the Proton Drive mount",
|
||||
))
|
||||
.tooltip_text(gettext("Choose where My files appears on this computer"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
mountpoint_button.add_css_class("flat");
|
||||
@@ -310,7 +328,10 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
||||
prefs.add(&storage);
|
||||
|
||||
// ---- Sidebar footer
|
||||
let status_icon = gtk4::Image::from_icon_name("content-loading-symbolic");
|
||||
// Until the first status arrives there is no state to show, only that the
|
||||
// app is still asking.
|
||||
let status_icon = gtk4::Image::builder().visible(false).build();
|
||||
let status_spinner = spinner();
|
||||
let status_title = gtk4::Label::builder()
|
||||
.label(gettext("Connecting…"))
|
||||
.halign(gtk4::Align::Start)
|
||||
@@ -329,6 +350,7 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
||||
status_text.append(&status_title);
|
||||
status_text.append(&status_detail);
|
||||
let status_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
||||
status_content.append(&status_spinner);
|
||||
status_content.append(&status_icon);
|
||||
status_content.append(&status_text);
|
||||
let status_button = gtk4::Button::builder()
|
||||
@@ -390,6 +412,7 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
||||
account_name,
|
||||
avatar,
|
||||
status_icon,
|
||||
status_spinner,
|
||||
status_title,
|
||||
status_detail,
|
||||
quota_box,
|
||||
@@ -491,7 +514,17 @@ pub(crate) fn wire_settings(
|
||||
ui.status
|
||||
.mountpoint_row
|
||||
.set_subtitle(&ui.dirs.resolved_mountpoint(&config).display().to_string());
|
||||
ui.status.autostart_row.set_active(service::is_enabled());
|
||||
// systemctl can take a while, so ask off the main thread and fill the
|
||||
// switch in when it answers, without firing its change handler.
|
||||
let ui_auto = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let enabled = gio::spawn_blocking(service::is_enabled)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
let suppress = ui_auto.status.settings_suppress.replace(true);
|
||||
ui_auto.status.autostart_row.set_active(enabled);
|
||||
ui_auto.status.settings_suppress.set(suppress);
|
||||
});
|
||||
ui.status
|
||||
.theme_row
|
||||
.set_active(config.proton_theme.unwrap_or(false));
|
||||
@@ -587,11 +620,14 @@ pub(crate) fn wire_settings(
|
||||
if ui_auto.status.settings_suppress.get() {
|
||||
return;
|
||||
}
|
||||
if row.is_active() {
|
||||
service::enable();
|
||||
} else {
|
||||
service::disable();
|
||||
}
|
||||
let enable = row.is_active();
|
||||
gio::spawn_blocking(move || {
|
||||
if enable {
|
||||
service::enable();
|
||||
} else {
|
||||
service::disable();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let ui_mp = ui.clone();
|
||||
@@ -606,8 +642,10 @@ pub(crate) fn wire_settings(
|
||||
let on = row.is_active();
|
||||
set_proton_theme(on);
|
||||
// Folders swap their icon with the theme; have every row bound again.
|
||||
let rows = ui_theme.browser.model.n_items();
|
||||
ui_theme.browser.model.items_changed(0, rows, rows);
|
||||
for list in file_lists(&ui_theme) {
|
||||
let rows = list.model.n_items();
|
||||
list.model.items_changed(0, rows, rows);
|
||||
}
|
||||
let mut config = ui_theme.dirs.load_config();
|
||||
config.proton_theme = Some(on);
|
||||
if let Err(e) = ui_theme.dirs.save_config(&config) {
|
||||
@@ -706,7 +744,11 @@ pub(crate) fn settings_request(ui: &Rc<Ui>, req: Request, done: String, failed:
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, &failed, &message, kind)
|
||||
}
|
||||
_ => toast_error(&ui, &failed, &gettext("The mount service didn't respond.")),
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
&failed,
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -716,7 +758,7 @@ pub(crate) fn settings_request(ui: &Rc<Ui>, req: Request, done: String, failed:
|
||||
pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = gtk4::FileDialog::builder()
|
||||
.title(gettext("Choose mountpoint folder"))
|
||||
.title(gettext("Choose Proton Drive Folder"))
|
||||
.build();
|
||||
let ui = ui.clone();
|
||||
dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| {
|
||||
@@ -728,34 +770,34 @@ pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
|
||||
let mut config = ui.dirs.load_config();
|
||||
config.mountpoint = Some(path_str.clone());
|
||||
if let Err(e) = ui.dirs.save_config(&config) {
|
||||
toast_error(&ui, &gettext("Couldn't save mountpoint"), &e.to_string());
|
||||
toast_error(&ui, &gettext("Couldn't save the folder"), &e.to_string());
|
||||
return;
|
||||
}
|
||||
ui.status.mountpoint_row.set_subtitle(&path_str);
|
||||
// The Locations page shows the same path as a row title; its cached rows
|
||||
// are now stale whether or not it is the visible page.
|
||||
ui.locations.loaded_at.set(None);
|
||||
// The Locations page shows the same path as a row title. Any other page
|
||||
// reads it again on the next visit.
|
||||
if ui.stack.visible_child_name().as_deref() == Some("locations") {
|
||||
load_locations(&ui);
|
||||
}
|
||||
|
||||
// The daemon only reads the mountpoint at mount time, so offer a restart.
|
||||
let confirm = adw::AlertDialog::builder()
|
||||
.heading(gettext("Restart to apply"))
|
||||
.heading(gettext("Restart Proton Drive?"))
|
||||
// Translators: {path} is the folder the drive is mounted at.
|
||||
.body(gettext_f(
|
||||
"The mountpoint is now “{path}”. Restart the Drive mount to use it?",
|
||||
"My files now appears in “{path}” after Proton Drive restarts.",
|
||||
&[("path", &path_str)],
|
||||
))
|
||||
.build();
|
||||
confirm.add_response("later", &gettext("Later"));
|
||||
confirm.add_response("restart", &gettext("Restart now"));
|
||||
confirm.add_response("restart", &gettext("Restart Now"));
|
||||
confirm.set_response_appearance("restart", adw::ResponseAppearance::Suggested);
|
||||
confirm.set_default_response(Some("restart"));
|
||||
confirm.set_close_response("later");
|
||||
confirm.connect_response(None, |_, resp| {
|
||||
let ui_restart = ui.clone();
|
||||
confirm.connect_response(None, move |_, resp| {
|
||||
if resp == "restart" {
|
||||
service::restart();
|
||||
restart_service_then(&ui_restart, |_| {});
|
||||
}
|
||||
});
|
||||
confirm.present(ui_window(&ui).as_ref());
|
||||
@@ -767,26 +809,27 @@ pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
|
||||
/// reload the page.
|
||||
pub(crate) fn wire_retry(ui: &Rc<Ui>) {
|
||||
let ui_browser = ui.clone();
|
||||
ui.browser.retry.clone().connect_clicked(move |_| {
|
||||
service::restart();
|
||||
load_browser(&ui_browser);
|
||||
});
|
||||
ui.browser
|
||||
.retry
|
||||
.clone()
|
||||
.connect_clicked(move |_| restart_service_then(&ui_browser, load_browser));
|
||||
let ui_gallery = ui.clone();
|
||||
ui.gallery.retry.clone().connect_clicked(move |_| {
|
||||
service::restart();
|
||||
load_gallery(&ui_gallery, false);
|
||||
});
|
||||
ui.gallery
|
||||
.retry
|
||||
.clone()
|
||||
.connect_clicked(move |_| restart_service_then(&ui_gallery, |ui| load_gallery(ui, false)));
|
||||
let ui_trash = ui.clone();
|
||||
ui.trash.retry.clone().connect_clicked(move |_| {
|
||||
service::restart();
|
||||
load_trash(&ui_trash);
|
||||
});
|
||||
ui.trash
|
||||
.retry
|
||||
.clone()
|
||||
.connect_clicked(move |_| restart_service_then(&ui_trash, load_trash));
|
||||
}
|
||||
|
||||
/// Repaint the window from the cached login identity, then kick an async mount-
|
||||
/// status fetch. Runs on the 2s tick: the identity check is instant (no keyring),
|
||||
/// and the status round-trip is offloaded to a worker so the main loop never
|
||||
/// blocks on a slow or wedged daemon.
|
||||
/// Repaint the window from the cached login identity, then ask the daemon for
|
||||
/// its status and transfers. Runs at startup, after a login and after a service
|
||||
/// restart; the daemon's event feed keeps both current in between. The identity
|
||||
/// check is instant (no keyring), and the round-trips run on workers so the
|
||||
/// main loop never blocks on a slow or wedged daemon.
|
||||
pub(crate) fn refresh(ui: &Rc<Ui>) {
|
||||
// Login identity decides which page is shown. Read the cached session — set
|
||||
// at startup and on login/logout — never the keyring.
|
||||
@@ -819,38 +862,14 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
|
||||
|
||||
refresh_status(ui);
|
||||
refresh_transfers(ui);
|
||||
// Both of these pages show work as it happens, so they follow the tick while
|
||||
// they are on screen. Every other page loads on navigation only.
|
||||
// A Takeout import outlives the page that started it, so it is polled
|
||||
// wherever the user has navigated to — otherwise leaving the page means
|
||||
// never being told it finished.
|
||||
ui.gallery
|
||||
.import_banner
|
||||
.set_revealed(ui.takeout.running.get());
|
||||
if ui.stack.visible_child_name().as_deref() == Some("takeout") || ui.takeout.running.get() {
|
||||
refresh_takeout(ui);
|
||||
}
|
||||
// The quota sits in the sidebar footer, visible on every page; its TTL
|
||||
// keeps this from asking more than once a minute.
|
||||
refresh_quota(ui);
|
||||
match ui.stack.visible_child_name().as_deref() {
|
||||
Some("locations") => {
|
||||
refresh_locations(ui);
|
||||
refresh_queue(ui);
|
||||
refresh_conflicts(ui, false);
|
||||
}
|
||||
Some("activity") => refresh_activity(ui),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a quota reading stays fresh. Account storage barely moves, so the
|
||||
/// active-page tick refetches it only this often rather than every 2s.
|
||||
/// How long a quota reading stays fresh. Account storage barely moves, so a
|
||||
/// status change reads it again only this often.
|
||||
const QUOTA_TTL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Fetch the account quota (if the last reading is stale) and paint both the
|
||||
/// sidebar footer and Files status bar. A failed fetch leaves the last
|
||||
/// good reading in place.
|
||||
/// Fetch the account quota (if the last reading is stale) and paint the
|
||||
/// sidebar footer. A failed fetch leaves the last good reading in place.
|
||||
pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
|
||||
if ui.status.quota_inflight.get() {
|
||||
return;
|
||||
@@ -874,10 +893,6 @@ pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
|
||||
ui.status.quota_checked_at.set(Some(Instant::now()));
|
||||
paint_account_quota(&ui, max_space, used_space);
|
||||
ui.status.quota_box.set_visible(true);
|
||||
} else if ui.status.quota_checked_at.get().is_none() {
|
||||
// Match Dolphin: capacity information does not occupy the bar until
|
||||
// the backing observer has real figures.
|
||||
ui.browser.quota_box.set_visible(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -886,15 +901,6 @@ fn paint_account_quota(ui: &Rc<Ui>, max_space: i64, used_space: i64) {
|
||||
let (fraction, text) = quota_display(max_space, used_space);
|
||||
ui.status.quota_bar.set_fraction(fraction);
|
||||
ui.status.quota_label.set_text(&text);
|
||||
if let Some((fraction, free_text, tooltip)) = quota_status_display(max_space, used_space) {
|
||||
ui.browser.quota.set_fraction(fraction);
|
||||
ui.browser.quota.set_tooltip_text(Some(&tooltip));
|
||||
ui.browser.quota_text.set_label(&free_text);
|
||||
ui.browser.quota_text.set_tooltip_text(Some(&tooltip));
|
||||
ui.browser.quota_box.set_visible(true);
|
||||
} else {
|
||||
ui.browser.quota_box.set_visible(false);
|
||||
}
|
||||
}
|
||||
|
||||
fn quota_display(max_space: i64, used_space: i64) -> (f64, String) {
|
||||
@@ -924,34 +930,6 @@ fn quota_display(max_space: i64, used_space: i64) -> (f64, String) {
|
||||
)
|
||||
}
|
||||
|
||||
/// Dolphin's status bar shows a bare capacity bar followed by “X free”; the
|
||||
/// full free/total/percentage sentence is a tooltip rather than inline bar text.
|
||||
fn quota_status_display(max_space: i64, used_space: i64) -> Option<(f64, String, String)> {
|
||||
if max_space <= 0 {
|
||||
return None;
|
||||
}
|
||||
let total = max_space as u64;
|
||||
let used = (used_space.max(0) as u64).min(total);
|
||||
let free = total.saturating_sub(used);
|
||||
let fraction = used as f64 / total as f64;
|
||||
let pct = (fraction * 100.0).round() as u64;
|
||||
Some((
|
||||
fraction,
|
||||
// Translators: {size} is an amount of storage, such as "1.2 GiB".
|
||||
gettext_f("{size} free", &[("size", &human_bytes(free))]),
|
||||
// Translators: account storage. {free} and {total} are sizes such as
|
||||
// "1.2 GiB"; {percent} is a whole number, followed by the percent sign.
|
||||
gettext_f(
|
||||
"{free} free out of {total} ({percent}% used)",
|
||||
&[
|
||||
("free", &human_bytes(free)),
|
||||
("total", &human_bytes(total)),
|
||||
("percent", &pct.to_string()),
|
||||
],
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
/// Record the mount state seen by the last status poll: gate every control that
|
||||
/// needs a live daemon, and notify the desktop when the state actually flips.
|
||||
///
|
||||
@@ -981,20 +959,20 @@ pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) {
|
||||
notify(
|
||||
"mount-state",
|
||||
&gettext("Proton Drive connected"),
|
||||
&gettext("Your Drive is mounted and available."),
|
||||
&gettext("Your files are available."),
|
||||
);
|
||||
} else {
|
||||
notify(
|
||||
"mount-state",
|
||||
&gettext("Proton Drive disconnected"),
|
||||
&gettext("The mount service stopped. Files aren't available until it restarts."),
|
||||
&gettext("The Proton Drive service stopped. Files aren't available until it restarts."),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll the daemon's in-flight transfers on a worker thread and repaint the
|
||||
/// Ask the daemon for its in-flight transfers on a worker thread and repaint the
|
||||
/// Activity group. Independently inflight-guarded from [`refresh_status`] so the
|
||||
/// two cheap polls on the 2s tick don't gate each other. The group hides itself
|
||||
/// two requests don't gate each other; the event feed pushes later snapshots. The group hides itself
|
||||
/// when nothing is moving, so an idle account shows no Activity section.
|
||||
pub(crate) fn refresh_transfers(ui: &Rc<Ui>) {
|
||||
if ui.status.transfers_inflight.get() {
|
||||
@@ -1036,17 +1014,35 @@ pub(crate) fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem], jobs: &[Job
|
||||
takeout_progress(ui, jobs);
|
||||
|
||||
let previous = ui.status.active_transfers.replace(items.len());
|
||||
if !items.is_empty() {
|
||||
let mut batch = ui.status.transfer_batch.borrow_mut();
|
||||
let batch = batch.get_or_insert_with(|| TransferBatch {
|
||||
started: Instant::now(),
|
||||
names: HashSet::new(),
|
||||
});
|
||||
batch
|
||||
.names
|
||||
.extend(items.iter().map(|item| item.name.clone()));
|
||||
}
|
||||
if items.is_empty() && previous > 0 {
|
||||
notify(
|
||||
"sync-complete",
|
||||
&gettext("Sync complete"),
|
||||
&ngettext_f(
|
||||
"{n} file finished transferring.",
|
||||
"{n} files finished transferring.",
|
||||
previous as u64,
|
||||
&[],
|
||||
),
|
||||
);
|
||||
// Nobody needs a notification for what they are watching happen.
|
||||
let watching = ui_window(ui).is_some_and(|window| window.is_active());
|
||||
if let Some(batch) = ui.status.transfer_batch.take()
|
||||
&& !watching
|
||||
&& (batch.names.len() >= SYNC_NOTIFY_MIN_FILES
|
||||
|| batch.started.elapsed() >= SYNC_NOTIFY_MIN_DURATION)
|
||||
{
|
||||
notify(
|
||||
"sync-complete",
|
||||
&gettext("Sync complete"),
|
||||
&ngettext_f(
|
||||
"{n} file finished transferring.",
|
||||
"{n} files finished transferring.",
|
||||
batch.names.len() as u64,
|
||||
&[],
|
||||
),
|
||||
);
|
||||
}
|
||||
// A just-finished batch may have added files (bulk upload) the current
|
||||
// listing doesn't show yet; refresh whichever listing is on screen.
|
||||
reload_listing(ui);
|
||||
@@ -1073,7 +1069,11 @@ pub(crate) fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem], jobs: &[Job
|
||||
let row_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||
row_box.set_margin_top(8);
|
||||
row_box.set_margin_bottom(8);
|
||||
let label = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
||||
// Ellipsized so a long file name cannot widen the page.
|
||||
let label = gtk4::Label::builder()
|
||||
.halign(gtk4::Align::Start)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
||||
.build();
|
||||
label.add_css_class("dim-label");
|
||||
let bar = gtk4::ProgressBar::new();
|
||||
row_box.append(&label);
|
||||
@@ -1169,7 +1169,10 @@ pub(crate) fn transfer_line(t: &TransferItem) -> ActivityLine {
|
||||
/// the mount line, cache bar and pin list on the reply. The daemon owns the cache
|
||||
/// stats now (`used`/`budget`/`pins` ride along on [`Response::Status`]), so the
|
||||
/// GUI never opens the on-disk cache itself. Skipped while a fetch is in flight so
|
||||
/// the tick can't stack threads on a stalled daemon.
|
||||
/// a stalled daemon can't pile up threads.
|
||||
///
|
||||
/// The event feed pushes every later change through [`paint_status`]; this asks
|
||||
/// only when the feed (re)connects or loses the daemon.
|
||||
pub(crate) fn refresh_status(ui: &Rc<Ui>) {
|
||||
if ui.status.status_inflight.get() {
|
||||
return;
|
||||
@@ -1181,75 +1184,102 @@ pub(crate) fn refresh_status(ui: &Rc<Ui>) {
|
||||
let result = rx.recv().await;
|
||||
ui.status.status_inflight.set(false);
|
||||
match result {
|
||||
Ok(Ok(Response::Status {
|
||||
mountpoint,
|
||||
used,
|
||||
budget,
|
||||
pins,
|
||||
online,
|
||||
pending_uploads,
|
||||
pending_changes,
|
||||
failing_ops,
|
||||
failing_error,
|
||||
paused,
|
||||
paused_until,
|
||||
..
|
||||
})) => {
|
||||
set_mounted(&ui, true);
|
||||
// The queue is the more useful thing to say when it has anything
|
||||
// in it: it is why a file that looks saved is not on the remote
|
||||
// yet, and offline is usually the reason it is still queued.
|
||||
let queued = i18n::pending_summary(pending_uploads, pending_changes);
|
||||
let state = if paused {
|
||||
SyncState::Paused {
|
||||
until: paused_until,
|
||||
queued,
|
||||
}
|
||||
} else if failing_ops > 0 {
|
||||
SyncState::Attention {
|
||||
count: failing_ops,
|
||||
error: failing_error,
|
||||
}
|
||||
} else if !online {
|
||||
SyncState::Offline { queued }
|
||||
} else if let Some(queued) = queued {
|
||||
SyncState::Syncing { queued }
|
||||
} else {
|
||||
SyncState::UpToDate { mountpoint }
|
||||
};
|
||||
paint_sync_status(&ui, state);
|
||||
let fraction = if budget == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(used as f64 / budget as f64).min(1.0)
|
||||
};
|
||||
ui.status.cache_bar.set_fraction(fraction);
|
||||
// A 0 budget means *unlimited*, not a zero-byte cap — "of 0 B
|
||||
// used" reads as a broken read-out, and there is no fraction to
|
||||
// draw against no limit.
|
||||
ui.status.cache_label.set_text(&if budget == 0 {
|
||||
format!("{} cached — no limit set", human_bytes(used))
|
||||
} else {
|
||||
format!("{} of {} used", human_bytes(used), human_bytes(budget))
|
||||
});
|
||||
repaint_pins(&ui, &pins, true);
|
||||
}
|
||||
// Daemon unreachable (still starting, or down): report not-mounted and
|
||||
// grey out the unpin buttons in place, but leave the last-known pin
|
||||
// rows and cache read-out so the page doesn't flicker on a blip.
|
||||
_ => {
|
||||
set_mounted(&ui, false);
|
||||
paint_sync_status(&ui, SyncState::Disconnected);
|
||||
for r in ui.status.pin_rows.borrow().iter() {
|
||||
if let Some(b) = &r.unpin {
|
||||
b.set_sensitive(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Ok(status @ Response::Status { .. })) => paint_status(&ui, status),
|
||||
// The daemon is up but stuck. Say so, and keep the mount state:
|
||||
// announcing "disconnected" for a slow reply would be wrong, and
|
||||
// the feed asks again when it reconnects.
|
||||
Ok(Err(e)) if e.is_timeout() => paint_sync_status(&ui, SyncState::NotResponding),
|
||||
_ => paint_disconnected(&ui),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Paint a [`Response::Status`], fetched or pushed: the mount state, the sync
|
||||
/// status, the cache bar and the pin list.
|
||||
pub(crate) fn paint_status(ui: &Rc<Ui>, status: Response) {
|
||||
let Response::Status {
|
||||
mountpoint,
|
||||
used,
|
||||
budget,
|
||||
pins,
|
||||
online,
|
||||
pending_uploads,
|
||||
pending_changes,
|
||||
failing_ops,
|
||||
failing_error,
|
||||
paused,
|
||||
paused_until,
|
||||
..
|
||||
} = status
|
||||
else {
|
||||
return;
|
||||
};
|
||||
set_mounted(ui, true);
|
||||
// The queue is the more useful thing to say when it has anything
|
||||
// in it: it is why a file that looks saved is not on the remote
|
||||
// yet, and offline is usually the reason it is still queued.
|
||||
let queued = i18n::pending_summary(pending_uploads, pending_changes);
|
||||
let state = if paused {
|
||||
SyncState::Paused {
|
||||
until: paused_until,
|
||||
queued,
|
||||
}
|
||||
} else if failing_ops > 0 {
|
||||
SyncState::Attention {
|
||||
count: failing_ops,
|
||||
error: failing_error,
|
||||
}
|
||||
} else if !online {
|
||||
SyncState::Offline { queued }
|
||||
} else if let Some(queued) = queued {
|
||||
SyncState::Syncing { queued }
|
||||
} else {
|
||||
SyncState::UpToDate { mountpoint }
|
||||
};
|
||||
paint_sync_status(ui, state);
|
||||
ui.locations.failing.set(failing_ops);
|
||||
paint_sync_banner(ui);
|
||||
let fraction = if budget == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(used as f64 / budget as f64).min(1.0)
|
||||
};
|
||||
ui.status.cache_bar.set_fraction(fraction);
|
||||
// A 0 budget means *unlimited*, not a zero-byte cap — "of 0 B
|
||||
// used" reads as a broken read-out, and there is no fraction to
|
||||
// draw against no limit.
|
||||
ui.status.cache_label.set_text(&if budget == 0 {
|
||||
// Translators: {used} is a size such as "1.2 GiB".
|
||||
gettext_f(
|
||||
"{used} cached — no limit set",
|
||||
&[("used", &human_bytes(used))],
|
||||
)
|
||||
} else {
|
||||
// Translators: {used} and {limit} are sizes such as "1.2 GiB".
|
||||
gettext_f(
|
||||
"{used} of {limit} used",
|
||||
&[
|
||||
("used", &human_bytes(used)),
|
||||
("limit", &human_bytes(budget)),
|
||||
],
|
||||
)
|
||||
});
|
||||
repaint_pins(ui, &pins, true);
|
||||
}
|
||||
|
||||
/// Daemon unreachable (still starting, or down): report not-mounted and grey
|
||||
/// out the unpin buttons in place, but leave the last-known pin rows and cache
|
||||
/// read-out so the page doesn't flicker on a blip.
|
||||
pub(crate) fn paint_disconnected(ui: &Rc<Ui>) {
|
||||
set_mounted(ui, false);
|
||||
paint_sync_status(ui, SyncState::Disconnected);
|
||||
for r in ui.status.pin_rows.borrow().iter() {
|
||||
if let Some(b) = &r.unpin {
|
||||
b.set_sensitive(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the sidebar's status strip reports, most urgent first.
|
||||
pub(crate) enum SyncState {
|
||||
/// The user paused syncing; nothing goes up until it resumes. Outranks
|
||||
@@ -1277,13 +1307,15 @@ pub(crate) enum SyncState {
|
||||
},
|
||||
/// No mount daemon answered: still starting, or the service is down.
|
||||
Disconnected,
|
||||
/// The daemon is running but didn't answer in time.
|
||||
NotResponding,
|
||||
}
|
||||
|
||||
/// Paint the sidebar status strip and the Sync page's status card: icon,
|
||||
/// one-line state, and a detail line.
|
||||
fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
||||
let paused = matches!(state, SyncState::Paused { .. });
|
||||
let connected = !matches!(state, SyncState::Disconnected);
|
||||
let connected = !matches!(state, SyncState::Disconnected | SyncState::NotResponding);
|
||||
let (icon, class, title, detail) = match state {
|
||||
SyncState::Paused { until, queued } => (
|
||||
"media-playback-pause-symbolic",
|
||||
@@ -1321,13 +1353,13 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
||||
Some(queued.unwrap_or_else(|| gettext("Cached files only"))),
|
||||
),
|
||||
SyncState::Syncing { queued } => (
|
||||
"emblem-synchronizing-symbolic",
|
||||
"pdfs-sync-symbolic",
|
||||
None,
|
||||
pgettext("state", "Syncing"),
|
||||
Some(queued),
|
||||
),
|
||||
SyncState::UpToDate { mountpoint } => (
|
||||
"emblem-ok-symbolic",
|
||||
"pdfs-synced-symbolic",
|
||||
Some("success"),
|
||||
gettext("Up to date"),
|
||||
Some(mountpoint),
|
||||
@@ -1338,9 +1370,17 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
||||
gettext("Not connected"),
|
||||
Some(gettext("Proton Drive isn't running")),
|
||||
),
|
||||
SyncState::NotResponding => (
|
||||
"dialog-warning-symbolic",
|
||||
Some("warning"),
|
||||
gettext("Not responding"),
|
||||
Some(gettext("Proton Drive is taking too long to answer")),
|
||||
),
|
||||
};
|
||||
let image = &ui.status.status_icon;
|
||||
image.set_icon_name(Some(icon));
|
||||
image.set_visible(true);
|
||||
ui.status.status_spinner.set_visible(false);
|
||||
for c in ["success", "warning", "error"] {
|
||||
image.remove_css_class(c);
|
||||
}
|
||||
@@ -1419,10 +1459,10 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
||||
))
|
||||
.build();
|
||||
ui.status.pins_group.add(&row);
|
||||
ui.status
|
||||
.pin_rows
|
||||
.borrow_mut()
|
||||
.push(PinRow { row, unpin: None });
|
||||
ui.status.pin_rows.borrow_mut().push(PinRow {
|
||||
row: row.upcast(),
|
||||
unpin: None,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1474,7 +1514,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
&gettext("Couldn't make it online only"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -1483,32 +1523,29 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
||||
|
||||
ui.status.pins_group.add(&row);
|
||||
ui.status.pin_rows.borrow_mut().push(PinRow {
|
||||
row,
|
||||
row: row.upcast(),
|
||||
unpin: Some(unpin),
|
||||
});
|
||||
}
|
||||
|
||||
if pins.len() > PINS_COLLAPSED {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(if expanded {
|
||||
gettext("Show fewer")
|
||||
} else {
|
||||
ngettext_f(
|
||||
"Show all {n} file",
|
||||
"Show all {n} files",
|
||||
pins.len() as u64,
|
||||
&[],
|
||||
)
|
||||
})
|
||||
.activatable(true)
|
||||
.build();
|
||||
row.add_suffix(>k4::Image::from_icon_name(if expanded {
|
||||
let title = if expanded {
|
||||
gettext("Show fewer")
|
||||
} else {
|
||||
ngettext_f(
|
||||
"Show all {n} file",
|
||||
"Show all {n} files",
|
||||
pins.len() as u64,
|
||||
&[],
|
||||
)
|
||||
};
|
||||
let icon = if expanded {
|
||||
"go-up-symbolic"
|
||||
} else {
|
||||
"go-down-symbolic"
|
||||
}));
|
||||
};
|
||||
let ui_more = ui.clone();
|
||||
row.connect_activated(move |_| {
|
||||
let row = button_row(&title, icon, move || {
|
||||
ui_more
|
||||
.status
|
||||
.pins_expanded
|
||||
@@ -1529,7 +1566,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{quota_display, quota_status_display};
|
||||
use super::quota_display;
|
||||
|
||||
#[test]
|
||||
fn quota_display_reports_used_total_and_percentage() {
|
||||
@@ -1544,18 +1581,4 @@ mod tests {
|
||||
assert_eq!(quota_display(0, -1), (0.0, "0 bytes used".to_string()));
|
||||
assert_eq!(quota_display(100, 150).0, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quota_status_display_matches_dolphin_wording() {
|
||||
let gib = 1024_i64.pow(3);
|
||||
assert_eq!(
|
||||
quota_status_display(4 * gib, gib),
|
||||
Some((
|
||||
0.25,
|
||||
"3.0 GiB free".to_string(),
|
||||
"3.0 GiB free out of 4.0 GiB (25% used)".to_string()
|
||||
))
|
||||
);
|
||||
assert_eq!(quota_status_display(0, 0), None);
|
||||
}
|
||||
}
|
||||
@@ -15,16 +15,16 @@
|
||||
//! 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,
|
||||
//! stages paths, launches, asks [`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`]).
|
||||
//! daemon already pushes (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";
|
||||
/// Matching on it is how this page borrows the progress the daemon already
|
||||
/// pushes instead of asking for it separately.
|
||||
pub(crate) 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
|
||||
@@ -384,7 +384,7 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
|
||||
let count = ui_import.takeout.archives.borrow().len();
|
||||
let ui = ui_import.clone();
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Import this export?"))
|
||||
.heading(gettext("Import This Export?"))
|
||||
.body(ngettext_f(
|
||||
"{n} archive will be uploaded to Proton Photos, creating albums as they appear in the export. This can take hours; you can stop it at any point and what has been uploaded stays. Photos you already have are skipped.",
|
||||
"{n} archives will be uploaded to Proton Photos, creating albums as they appear in the export. This can take hours; you can stop it at any point and what has been uploaded stays. Photos you already have are skipped.",
|
||||
@@ -421,7 +421,7 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
&gettext("Couldn't stop the import"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -589,6 +589,7 @@ fn start_import(ui: &Rc<Ui>, dry_run: bool) {
|
||||
match result {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
ui.takeout.running.set(true);
|
||||
ui.gallery.import_banner.set_revealed(true);
|
||||
ui.takeout.summary_group.set_visible(false);
|
||||
ui.takeout.progress_group.set_visible(true);
|
||||
ui.takeout.progress_bar.set_fraction(0.0);
|
||||
@@ -613,15 +614,15 @@ fn start_import(ui: &Rc<Ui>, dry_run: bool) {
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
&gettext("Couldn't start the import"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
&gettext("The Proton Drive 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.
|
||||
/// Ask the daemon for the import's state, and paint the report once a run has
|
||||
/// finished. Asked on arrival and while import progress keeps arriving. Cheap
|
||||
/// and inflight-guarded.
|
||||
pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
|
||||
if ui.takeout.inflight.get() {
|
||||
return;
|
||||
@@ -636,6 +637,7 @@ pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
|
||||
return;
|
||||
};
|
||||
let was_running = ui.takeout.running.replace(running);
|
||||
ui.gallery.import_banner.set_revealed(running);
|
||||
if running {
|
||||
ui.takeout.progress_group.set_visible(true);
|
||||
} else {
|
||||
@@ -776,8 +778,8 @@ fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary) {
|
||||
ui.takeout.summary_group.set_visible(true);
|
||||
}
|
||||
|
||||
/// Paint the import's progress from the transfer snapshot the status tick
|
||||
/// already fetched, so this page costs no extra poll. A job with no known total
|
||||
/// Paint the import's progress from the transfer snapshot the daemon pushed,
|
||||
/// so this page costs no extra request. 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 {
|
||||
|
||||
@@ -3,55 +3,51 @@ use crate::*;
|
||||
pub(crate) struct TrashState {
|
||||
// Trash page. Trashed nodes are addressed by uid, not by path, so this page
|
||||
// keeps no current-directory state — it re-lists from the daemon on show.
|
||||
pub(crate) model: gio::ListStore,
|
||||
pub(crate) files: FileList,
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
/// Empties the trash; insensitive while it is empty (or unread).
|
||||
/// Empties the trash; hidden while it is empty (or unread).
|
||||
pub(crate) empty: gtk4::Button,
|
||||
/// "12 items" under the page title.
|
||||
pub(crate) subtitle: adw::WindowTitle,
|
||||
pub(crate) selection: gtk4::MultiSelection,
|
||||
/// The bottom bar acting on the selection; revealed while anything is selected.
|
||||
pub(crate) selection_bar: gtk4::Revealer,
|
||||
pub(crate) selection_label: gtk4::Label,
|
||||
/// Runs the loads; the rows stay up while one runs.
|
||||
pub(crate) loader: Rc<Loader>,
|
||||
}
|
||||
|
||||
/// The widgets of the Trash page that a load repaints.
|
||||
pub(crate) struct TrashWidgets {
|
||||
pub(crate) model: gio::ListStore,
|
||||
pub(crate) list: gtk4::ListView,
|
||||
pub(crate) files: FileList,
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) empty: gtk4::Button,
|
||||
pub(crate) refresh: gtk4::Button,
|
||||
pub(crate) subtitle: adw::WindowTitle,
|
||||
pub(crate) selection: gtk4::MultiSelection,
|
||||
pub(crate) selection_bar: gtk4::Revealer,
|
||||
pub(crate) selection_label: gtk4::Label,
|
||||
pub(crate) restore_selected: gtk4::Button,
|
||||
pub(crate) delete_selected: gtk4::Button,
|
||||
}
|
||||
|
||||
/// The Trash page: a flat list of everything Drive is holding in the trash, each
|
||||
/// row offering Restore and Delete Forever, with Empty Trash in the header. A
|
||||
/// selection (click, Shift/Ctrl+click, Ctrl+A) gets the same two actions in a
|
||||
/// bottom bar.
|
||||
/// The Trash page: everything Drive is holding in the trash, as a list with
|
||||
/// the folder each item came from and when it was deleted, or as a grid, where
|
||||
/// photos are easier to recognise. Restore and Delete Permanently sit in the
|
||||
/// right-click menu and, for a selection, in a bottom bar; Empty Trash is in
|
||||
/// the header while there is something to empty.
|
||||
///
|
||||
/// A trashed node has no path inside the mount — the daemon forgets it when it is
|
||||
/// trashed — so unlike the Files page this one addresses entries by uid and always
|
||||
/// re-lists from the server rather than from a cached listing. Row rendering needs
|
||||
/// the [`Ui`] handle for its buttons, so the factory is installed in [`wire_trash`].
|
||||
/// A trashed node has no path inside the mount — the daemon forgets it when it
|
||||
/// is trashed — so unlike the Files page this one addresses entries by uid. The
|
||||
/// list's factories need the [`Ui`] handle, so they are installed in
|
||||
/// [`wire_trash`].
|
||||
pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
||||
let model = gio::ListStore::new::<BoxedAnyObject>();
|
||||
|
||||
let empty = gtk4::Button::builder()
|
||||
.label(gettext("Empty Trash…"))
|
||||
.tooltip_text(gettext("Permanently delete everything in the Trash"))
|
||||
.sensitive(false)
|
||||
.visible(false)
|
||||
.build();
|
||||
let refresh = refresh_button();
|
||||
|
||||
let retry = gtk4::Button::builder()
|
||||
.label(gettext("Retry"))
|
||||
@@ -67,28 +63,27 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
||||
.build();
|
||||
status.add_css_class("compact");
|
||||
|
||||
// Bulk Delete Forever always confirms with the count, so a stale selection
|
||||
// can't delete anything silently.
|
||||
let selection = gtk4::MultiSelection::new(Some(model.clone()));
|
||||
let list = gtk4::ListView::builder().model(&selection).build();
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.child(&list)
|
||||
.build();
|
||||
// The list first: where an item came from and when it went are what tell
|
||||
// two copies of a file apart here.
|
||||
let files = FileList::new();
|
||||
files.show_list(true);
|
||||
let layout = layout_button(&files);
|
||||
|
||||
let content = gtk4::Stack::new();
|
||||
content.set_vexpand(true);
|
||||
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
||||
content.add_named(&scroll, Some("list"));
|
||||
content.add_named(&files.views, Some("list"));
|
||||
content.add_named(&status, Some("status"));
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
inner.set_margin_top(18);
|
||||
inner.set_margin_bottom(18);
|
||||
inner.set_margin_start(18);
|
||||
inner.set_margin_end(18);
|
||||
inner.set_margin_top(12);
|
||||
inner.set_margin_bottom(12);
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&content);
|
||||
|
||||
// Bulk Delete Permanently always confirms with the count, so a stale
|
||||
// selection can't delete anything silently.
|
||||
let selection_label = gtk4::Label::new(None);
|
||||
let restore_selected = gtk4::Button::with_label(&pgettext("verb", "Restore"));
|
||||
let delete_selected = gtk4::Button::with_label(&gettext("Delete Permanently…"));
|
||||
@@ -105,20 +100,17 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
||||
|
||||
let (frame, header, subtitle) = page_frame(&gettext("Trash"), &inner);
|
||||
header.pack_start(&empty);
|
||||
header.pack_end(&refresh);
|
||||
header.pack_end(&layout);
|
||||
|
||||
(
|
||||
frame.upcast(),
|
||||
TrashWidgets {
|
||||
model,
|
||||
list,
|
||||
files,
|
||||
content,
|
||||
status,
|
||||
retry,
|
||||
empty,
|
||||
refresh,
|
||||
subtitle,
|
||||
selection,
|
||||
selection_bar,
|
||||
selection_label,
|
||||
restore_selected,
|
||||
@@ -127,142 +119,144 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
||||
)
|
||||
}
|
||||
|
||||
/// Install the row factory and the Empty Trash button. The row's two buttons read
|
||||
/// the entry off the [`gtk4::ListItem`] they were clicked on rather than a
|
||||
/// captured copy, so a recycled row always acts on the item it currently shows.
|
||||
/// Install the list's factories and columns, the Empty Trash button and the
|
||||
/// selection bar.
|
||||
pub(crate) fn wire_trash(ui: &Rc<Ui>, widgets: &TrashWidgets) {
|
||||
let list = &widgets.list;
|
||||
let empty = &widgets.empty;
|
||||
let factory = gtk4::SignalListItemFactory::new();
|
||||
let ui_setup = ui.clone();
|
||||
factory.connect_setup(move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
|
||||
let thumbnail = file_thumbnail_widget(40, 32);
|
||||
let name = gtk4::Label::builder()
|
||||
.halign(gtk4::Align::Start)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
||||
.build();
|
||||
let meta = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
||||
meta.add_css_class("dim-label");
|
||||
meta.add_css_class("caption");
|
||||
let text = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
||||
text.set_hexpand(true);
|
||||
text.set_valign(gtk4::Align::Center);
|
||||
text.append(&name);
|
||||
text.append(&meta);
|
||||
|
||||
let restore = gtk4::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.tooltip_text(gettext("Restore to its original folder"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
restore.add_css_class("flat");
|
||||
let purge = gtk4::Button::builder()
|
||||
.icon_name("edit-delete-symbolic")
|
||||
.tooltip_text(gettext("Delete permanently"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
purge.add_css_class("flat");
|
||||
|
||||
let ui_restore = ui_setup.clone();
|
||||
let item_restore = item.clone();
|
||||
restore.connect_clicked(move |_| {
|
||||
if let Some(entry) = bound_entry(&item_restore) {
|
||||
restore_entry(&ui_restore, &entry);
|
||||
let files = &ui.trash.files;
|
||||
files.wire(
|
||||
ui,
|
||||
FileListBehavior {
|
||||
activate: activate_trashed,
|
||||
entry_menu: trash_entry_menu,
|
||||
bulk_menu: trash_bulk_menu,
|
||||
background_menu: trash_background_menu,
|
||||
badges: false,
|
||||
drag_and_drop: false,
|
||||
},
|
||||
);
|
||||
files
|
||||
.column_view
|
||||
.append_column(&text_column(&gettext("Original Location"), |e| {
|
||||
trashed_from_label(e)
|
||||
}));
|
||||
files
|
||||
.column_view
|
||||
.append_column(&text_column(&pgettext("column", "Deleted"), |e| {
|
||||
if e.trashed_at > 0 {
|
||||
dates::relative(e.trashed_at)
|
||||
} else {
|
||||
"—".to_string()
|
||||
}
|
||||
});
|
||||
let ui_purge = ui_setup.clone();
|
||||
let item_purge = item.clone();
|
||||
purge.connect_clicked(move |_| {
|
||||
if let Some(entry) = bound_entry(&item_purge) {
|
||||
prompt_delete_forever(&ui_purge, std::slice::from_ref(&entry));
|
||||
}));
|
||||
files
|
||||
.column_view
|
||||
.append_column(&text_column(&pgettext("column", "Size"), |e| {
|
||||
if e.is_dir {
|
||||
"—".to_string()
|
||||
} else {
|
||||
human_bytes(e.size)
|
||||
}
|
||||
});
|
||||
|
||||
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
|
||||
row.set_margin_top(6);
|
||||
row.set_margin_bottom(6);
|
||||
row.set_margin_start(6);
|
||||
row.set_margin_end(6);
|
||||
row.append(&thumbnail);
|
||||
row.append(&text);
|
||||
row.append(&restore);
|
||||
row.append(&purge);
|
||||
item.set_child(Some(&row));
|
||||
});
|
||||
factory.connect_bind({
|
||||
let ui = ui.clone();
|
||||
move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let Some(entry) = bound_entry(item) else {
|
||||
return;
|
||||
};
|
||||
let row = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||
let Some(thumbnail) = row.first_child().and_downcast::<gtk4::Overlay>() else {
|
||||
return;
|
||||
};
|
||||
bind_file_thumbnail(&ui, &thumbnail, &entry, true);
|
||||
let Some(text) = thumbnail.next_sibling().and_downcast::<gtk4::Box>() else {
|
||||
return;
|
||||
};
|
||||
if let Some(name) = text.first_child().and_downcast::<gtk4::Label>() {
|
||||
name.set_label(&entry.name);
|
||||
}
|
||||
if let Some(meta) = text.last_child().and_downcast::<gtk4::Label>() {
|
||||
let kind = if entry.is_dir {
|
||||
gettext("Folder")
|
||||
} else {
|
||||
human_bytes(entry.size)
|
||||
};
|
||||
let modified = format_modified(entry.modified);
|
||||
// Translators: {kind} is "Folder" or a file size; {modified} is a date.
|
||||
let text = gettext_f(
|
||||
"{kind} · {modified}",
|
||||
&[("kind", &kind), ("modified", &modified)],
|
||||
);
|
||||
meta.set_label(&text);
|
||||
}
|
||||
}
|
||||
});
|
||||
list.set_factory(Some(&factory));
|
||||
}));
|
||||
|
||||
let ui_empty = ui.clone();
|
||||
empty.connect_clicked(move |_| prompt_empty_trash(&ui_empty));
|
||||
widgets
|
||||
.empty
|
||||
.connect_clicked(move |_| prompt_empty_trash(&ui_empty));
|
||||
|
||||
let ui_sel = ui.clone();
|
||||
widgets
|
||||
files
|
||||
.selection
|
||||
.connect_selection_changed(move |_, _, _| sync_trash_selection(&ui_sel));
|
||||
let ui_restore = ui.clone();
|
||||
widgets.restore_selected.connect_clicked(move |_| {
|
||||
let entries = selected_trash(&ui_restore);
|
||||
let entries = ui_restore.trash.files.selected();
|
||||
if !entries.is_empty() {
|
||||
restore_entries(&ui_restore, &entries);
|
||||
}
|
||||
});
|
||||
let ui_delete = ui.clone();
|
||||
widgets.delete_selected.connect_clicked(move |_| {
|
||||
let entries = selected_trash(&ui_delete);
|
||||
let entries = ui_delete.trash.files.selected();
|
||||
if !entries.is_empty() {
|
||||
prompt_delete_forever(&ui_delete, &entries);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The trashed entries currently selected, in list order.
|
||||
fn selected_trash(ui: &Rc<Ui>) -> Vec<DirEntry> {
|
||||
let selected = ui.trash.selection.selection();
|
||||
(0..selected.size())
|
||||
.filter_map(|i| ui.trash.model.item(selected.nth(i as u32)))
|
||||
.filter_map(|obj| obj.downcast::<BoxedAnyObject>().ok())
|
||||
.map(|obj| obj.borrow::<DirEntry>().clone())
|
||||
.collect()
|
||||
/// Where a trashed entry goes back to on restore, for the Original Location
|
||||
/// column.
|
||||
fn trashed_from_label(entry: &DirEntry) -> String {
|
||||
match entry.trashed_from.as_deref() {
|
||||
None => "—".to_string(),
|
||||
Some("") => gettext("My files"),
|
||||
Some(path) => path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A trashed file can't be opened; say what would let it be.
|
||||
fn activate_trashed(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
toast(
|
||||
ui,
|
||||
// Translators: {name} is a file or folder name.
|
||||
&gettext_f("Restore “{name}” to open it", &[("name", &entry.name)]),
|
||||
);
|
||||
}
|
||||
|
||||
fn trash_entry_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||
let mut menu = ActionMenu::new();
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
menu.item(&pgettext("verb", "Restore"), move || {
|
||||
restore_entries(&ui_c, std::slice::from_ref(&entry_c))
|
||||
});
|
||||
menu.section();
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
menu.item(&gettext("Delete Permanently…"), move || {
|
||||
prompt_delete_forever(&ui_c, std::slice::from_ref(&entry_c))
|
||||
});
|
||||
menu
|
||||
}
|
||||
|
||||
fn trash_bulk_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu {
|
||||
let mut menu = ActionMenu::new();
|
||||
menu.labelled_section(&ngettext_f(
|
||||
"{n} selected",
|
||||
"{n} selected",
|
||||
entries.len() as u64,
|
||||
&[],
|
||||
));
|
||||
let (ui_c, batch) = (ui.clone(), entries.clone());
|
||||
menu.item(&pgettext("verb", "Restore"), move || {
|
||||
restore_entries(&ui_c, &batch)
|
||||
});
|
||||
menu.section();
|
||||
let ui_c = ui.clone();
|
||||
menu.item(&gettext("Delete Permanently…"), move || {
|
||||
prompt_delete_forever(&ui_c, &entries)
|
||||
});
|
||||
menu
|
||||
}
|
||||
|
||||
fn trash_background_menu(ui: &Rc<Ui>) -> ActionMenu {
|
||||
let mut menu = ActionMenu::new();
|
||||
let ui_c = ui.clone();
|
||||
menu.item(&gettext("Select All"), move || {
|
||||
ui_c.trash.files.selection.select_all();
|
||||
});
|
||||
let ui_c = ui.clone();
|
||||
menu.item(&gettext("Refresh"), move || reload_current_page(&ui_c));
|
||||
if ui.trash.files.model.n_items() > 0 {
|
||||
menu.section();
|
||||
let ui_c = ui.clone();
|
||||
menu.item(&gettext("Empty Trash…"), move || {
|
||||
prompt_empty_trash(&ui_c)
|
||||
});
|
||||
}
|
||||
menu
|
||||
}
|
||||
|
||||
/// Reveal the selection bar while anything is selected and count what is.
|
||||
fn sync_trash_selection(ui: &Rc<Ui>) {
|
||||
let count = ui.trash.selection.selection().size() as usize;
|
||||
let count = ui.trash.files.selection.selection().size() as usize;
|
||||
ui.trash.selection_bar.set_reveal_child(count > 0);
|
||||
if count > 0 {
|
||||
ui.trash.selection_label.set_label(&ngettext_f(
|
||||
@@ -274,29 +268,24 @@ fn sync_trash_selection(ui: &Rc<Ui>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`DirEntry`] a list item is currently bound to, or `None` for an unbound
|
||||
/// (recycled) row.
|
||||
pub(crate) fn bound_entry(item: >k4::ListItem) -> Option<DirEntry> {
|
||||
let obj = item.item().and_downcast::<BoxedAnyObject>()?;
|
||||
let entry = obj.borrow::<DirEntry>().clone();
|
||||
Some(entry)
|
||||
}
|
||||
|
||||
/// Fetch the trash listing and repaint the page.
|
||||
pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
||||
cancel_file_thumbnails(ui);
|
||||
// Drop the old rows first: a stale row here would offer Restore on something
|
||||
// that may already be gone.
|
||||
ui.trash.model.remove_all();
|
||||
ui.trash.empty.set_sensitive(false);
|
||||
sync_trash_selection(ui);
|
||||
trash_status(
|
||||
ui,
|
||||
"user-trash-symbolic",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading the trash."),
|
||||
false,
|
||||
);
|
||||
// The rows stay up while the trash is read again. Trashed items are
|
||||
// addressed by uid, so acting on one that has gone meanwhile fails
|
||||
// cleanly rather than hitting something else.
|
||||
let ui_p = ui.clone();
|
||||
let ticket = ui.trash.loader.refresh(move || {
|
||||
clear_trash(&ui_p);
|
||||
ui_p.trash.empty.set_sensitive(false);
|
||||
trash_status(
|
||||
&ui_p,
|
||||
"user-trash-symbolic",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading the trash."),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListTrash);
|
||||
@@ -304,6 +293,12 @@ pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
if !ticket.is_current() {
|
||||
return;
|
||||
}
|
||||
if !matches!(result, Ok(Ok(Response::Entries { .. }))) {
|
||||
clear_trash(&ui);
|
||||
}
|
||||
match result {
|
||||
Ok(Ok(Response::Entries { entries })) => repaint_trash(&ui, &entries),
|
||||
Ok(Ok(Response::Error { message, .. })) => trash_status(
|
||||
@@ -317,7 +312,7 @@ pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
&gettext("Couldn't read the trash"),
|
||||
&gettext("Unexpected reply from the mount service."),
|
||||
&gettext("Unexpected reply from the Proton Drive service."),
|
||||
false,
|
||||
),
|
||||
Ok(Err(_)) | Err(_) => trash_unreachable(&ui),
|
||||
@@ -328,29 +323,7 @@ pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
||||
/// The daemon didn't answer. Same split as the Files page: still starting (poll
|
||||
/// again, no button) versus actually down (Retry, which restarts the service).
|
||||
pub(crate) fn trash_unreachable(ui: &Rc<Ui>) {
|
||||
if service::is_failed() || !service::is_active() {
|
||||
trash_status(
|
||||
ui,
|
||||
"network-offline-symbolic",
|
||||
&gettext("Not connected"),
|
||||
&gettext("The Proton Drive mount service isn't running."),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
trash_status(
|
||||
ui,
|
||||
"folder-remote-symbolic",
|
||||
&gettext("Connecting…"),
|
||||
&gettext("Waiting for the Proton Drive mount service to come up."),
|
||||
false,
|
||||
);
|
||||
let ui = ui.clone();
|
||||
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
|
||||
if ui.stack.visible_child_name().as_deref() == Some("trash") {
|
||||
load_trash(&ui);
|
||||
}
|
||||
});
|
||||
service_unreachable(ui, "trash", trash_status, load_trash);
|
||||
}
|
||||
|
||||
/// Show a status page in place of the trash list.
|
||||
@@ -363,12 +336,13 @@ pub(crate) fn trash_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &s
|
||||
ui.trash.subtitle.set_subtitle("");
|
||||
}
|
||||
|
||||
/// Repopulate the trash list, most recently modified first — the order in which a
|
||||
/// user looks for what they just deleted.
|
||||
/// Repopulate the trash, most recently deleted first: the order in which a user
|
||||
/// looks for what they just deleted.
|
||||
pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
ui.trash.model.remove_all();
|
||||
sync_trash_selection(ui);
|
||||
ui.trash.empty.set_sensitive(!entries.is_empty());
|
||||
// An empty Trash has nothing to empty, so the button goes away rather than
|
||||
// sitting there greyed out.
|
||||
ui.trash.empty.set_sensitive(true);
|
||||
ui.trash.empty.set_visible(!entries.is_empty());
|
||||
if entries.is_empty() {
|
||||
trash_status(
|
||||
ui,
|
||||
@@ -377,6 +351,7 @@ pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
&gettext("Items you delete from Proton Drive show up here."),
|
||||
false,
|
||||
);
|
||||
clear_trash(ui);
|
||||
return;
|
||||
}
|
||||
ui.trash.content.set_visible_child_name("list");
|
||||
@@ -388,38 +363,69 @@ pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
));
|
||||
|
||||
let mut sorted = entries.to_vec();
|
||||
sorted.sort_by_key(|e| std::cmp::Reverse(e.modified));
|
||||
for entry in sorted {
|
||||
ui.trash.model.append(&BoxedAnyObject::new(entry));
|
||||
}
|
||||
sort_trash(&mut sorted);
|
||||
// Only the rows that changed are swapped, so a refresh keeps the
|
||||
// selection and the scroll position.
|
||||
replace_items(&ui.trash.files.model, &sorted);
|
||||
sync_trash_selection(ui);
|
||||
}
|
||||
|
||||
/// Restore one trashed entry to the folder it was trashed from.
|
||||
pub(crate) fn restore_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
restore_entries(ui, std::slice::from_ref(entry));
|
||||
/// Newest deletion first. Entries with no known deletion time go last, newest
|
||||
/// modification first among themselves.
|
||||
fn sort_trash(entries: &mut [DirEntry]) {
|
||||
entries.sort_by_key(|e| {
|
||||
(
|
||||
e.trashed_at == 0,
|
||||
std::cmp::Reverse(e.trashed_at),
|
||||
std::cmp::Reverse(e.modified),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/// Restore trashed entries to the folders they were trashed from.
|
||||
/// Drop every row, and with them the selection and its bar.
|
||||
fn clear_trash(ui: &Rc<Ui>) {
|
||||
ui.trash.files.model.remove_all();
|
||||
sync_trash_selection(ui);
|
||||
}
|
||||
|
||||
/// Restore trashed entries to the folders they were trashed from. When they
|
||||
/// all came from one known folder, the toast offers to show it.
|
||||
pub(crate) fn restore_entries(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
run_mutation(
|
||||
let message = match entries {
|
||||
// Translators: {name} is a file or folder name.
|
||||
[one] => gettext_f("Restored “{name}”", &[("name", &one.name)]),
|
||||
_ => ngettext_f(
|
||||
"Restored {n} item",
|
||||
"Restored {n} items",
|
||||
entries.len() as u64,
|
||||
&[],
|
||||
),
|
||||
};
|
||||
let folder = common_origin(entries);
|
||||
run_mutation_then(
|
||||
ui,
|
||||
Request::Restore {
|
||||
uids: entries.iter().map(|e| e.uid.clone()).collect(),
|
||||
},
|
||||
match entries {
|
||||
// Translators: {name} is a file or folder name.
|
||||
[one] => gettext_f("Restored “{name}”", &[("name", &one.name)]),
|
||||
_ => ngettext_f(
|
||||
"Restored {n} item",
|
||||
"Restored {n} items",
|
||||
entries.len() as u64,
|
||||
&[],
|
||||
),
|
||||
},
|
||||
gettext_noop("Couldn't restore"),
|
||||
move |ui| match folder {
|
||||
Some(folder) => toast_action(ui, &message, &gettext("Show"), move |ui| {
|
||||
open_in_my_files(ui, folder.clone())
|
||||
}),
|
||||
None => toast(ui, &message),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The folder every entry goes back to, when that is one known folder.
|
||||
fn common_origin(entries: &[DirEntry]) -> Option<String> {
|
||||
let first = entries.first()?.trashed_from.clone()?;
|
||||
entries
|
||||
.iter()
|
||||
.all(|e| e.trashed_from.as_deref() == Some(first.as_str()))
|
||||
.then_some(first)
|
||||
}
|
||||
|
||||
/// Confirm, then permanently delete trashed entries. Irreversible, so it asks.
|
||||
pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
let win = ui_window(ui);
|
||||
@@ -451,7 +457,7 @@ pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
),
|
||||
};
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Delete Permanently"))
|
||||
.heading(gettext("Delete Permanently?"))
|
||||
.body(body)
|
||||
.build();
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
@@ -477,9 +483,9 @@ pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
/// Confirm, then permanently delete everything in the trash.
|
||||
pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
|
||||
let win = ui_window(ui);
|
||||
let count = ui.trash.model.n_items();
|
||||
let count = ui.trash.files.model.n_items();
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Empty Trash"))
|
||||
.heading(gettext("Empty Trash?"))
|
||||
.body(ngettext_f(
|
||||
"Permanently delete all {n} item in the trash? This cannot be undone.",
|
||||
"Permanently delete all {n} items in the trash? This cannot be undone.",
|
||||
@@ -506,3 +512,59 @@ pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn trashed(name: &str, trashed_at: i64, modified: i64, from: Option<&str>) -> DirEntry {
|
||||
DirEntry {
|
||||
name: name.into(),
|
||||
is_dir: false,
|
||||
size: 0,
|
||||
modified,
|
||||
pinned: false,
|
||||
cached: false,
|
||||
uid: name.into(),
|
||||
path: String::new(),
|
||||
role: String::new(),
|
||||
shared_by: String::new(),
|
||||
shared_at: 0,
|
||||
shared_by_unverified: false,
|
||||
trashed_at,
|
||||
trashed_from: from.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trash_lists_newest_deletion_first_and_undated_last() {
|
||||
let mut entries = vec![
|
||||
trashed("undated-old", 0, 1, None),
|
||||
trashed("older", 10, 50, None),
|
||||
trashed("undated-new", 0, 9, None),
|
||||
trashed("newest", 20, 5, None),
|
||||
];
|
||||
sort_trash(&mut entries);
|
||||
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
|
||||
assert_eq!(names, ["newest", "older", "undated-new", "undated-old"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_offers_a_folder_only_when_all_share_one() {
|
||||
let docs = trashed("a", 1, 1, Some("Documents"));
|
||||
let docs2 = trashed("b", 1, 1, Some("Documents"));
|
||||
let root = trashed("c", 1, 1, Some(""));
|
||||
let unknown = trashed("d", 1, 1, None);
|
||||
assert_eq!(
|
||||
common_origin(&[docs.clone(), docs2]).as_deref(),
|
||||
Some("Documents")
|
||||
);
|
||||
assert_eq!(
|
||||
common_origin(std::slice::from_ref(&root)).as_deref(),
|
||||
Some("")
|
||||
);
|
||||
assert_eq!(common_origin(&[docs.clone(), root]), None);
|
||||
assert_eq!(common_origin(&[docs, unknown]), None);
|
||||
assert_eq!(common_origin(&[]), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! Reloading a page without flashing it.
|
||||
//!
|
||||
//! Pages used to swap their content for a "Loading…" status page on every
|
||||
//! load, so every sidebar click and every folder change blinked. A [`Loader`]
|
||||
//! keeps the last content on screen while the request runs and shows the
|
||||
//! loading state only if the reply is slow. [`replace_items`] then swaps in the
|
||||
//! new rows by changing only the ones that differ, so selection and scroll
|
||||
//! position survive a refresh.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use gtk4::gio;
|
||||
use gtk4::glib;
|
||||
use gtk4::glib::BoxedAnyObject;
|
||||
use gtk4::glib::object::Cast;
|
||||
use gtk4::prelude::*;
|
||||
|
||||
/// How long a load may run before the page gives up its old content for a
|
||||
/// loading state. Most replies arrive well within this, so the page updates in
|
||||
/// place.
|
||||
const LOADING_GRACE: Duration = Duration::from_millis(300);
|
||||
|
||||
/// One page's loads. Each [`Loader::refresh`] or [`Loader::replace`]
|
||||
/// supersedes the one before it.
|
||||
pub(crate) struct Loader {
|
||||
content: gtk4::Widget,
|
||||
generation: Cell<u64>,
|
||||
placeholder: RefCell<Option<glib::SourceId>>,
|
||||
}
|
||||
|
||||
impl Loader {
|
||||
/// A loader for the page whose content is `content`.
|
||||
pub(crate) fn new(content: &impl IsA<gtk4::Widget>) -> Rc<Self> {
|
||||
Rc::new(Loader {
|
||||
content: content.clone().upcast(),
|
||||
generation: Cell::new(0),
|
||||
placeholder: RefCell::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
/// Start a load of what the page already shows. The content stays up and
|
||||
/// usable; `placeholder` runs if the load is still going after
|
||||
/// [`LOADING_GRACE`].
|
||||
pub(crate) fn refresh(self: &Rc<Self>, placeholder: impl FnOnce() + 'static) -> LoadTicket {
|
||||
self.begin(false, placeholder)
|
||||
}
|
||||
|
||||
/// Start a load of something else, such as another folder. The old
|
||||
/// content stays up but insensitive, since acting on it would act on the
|
||||
/// wrong thing; `placeholder` runs if the load is still going after
|
||||
/// [`LOADING_GRACE`].
|
||||
pub(crate) fn replace(self: &Rc<Self>, placeholder: impl FnOnce() + 'static) -> LoadTicket {
|
||||
self.begin(true, placeholder)
|
||||
}
|
||||
|
||||
fn begin(self: &Rc<Self>, stale: bool, placeholder: impl FnOnce() + 'static) -> LoadTicket {
|
||||
let generation = self.generation.get().wrapping_add(1);
|
||||
self.generation.set(generation);
|
||||
self.cancel_placeholder();
|
||||
self.content.set_sensitive(!stale);
|
||||
let loader = Rc::downgrade(self);
|
||||
let source = glib::timeout_add_local_once(LOADING_GRACE, move || {
|
||||
let Some(loader) = loader.upgrade() else {
|
||||
return;
|
||||
};
|
||||
loader.placeholder.borrow_mut().take();
|
||||
loader.content.set_sensitive(true);
|
||||
placeholder();
|
||||
});
|
||||
*self.placeholder.borrow_mut() = Some(source);
|
||||
LoadTicket {
|
||||
loader: self.clone(),
|
||||
generation,
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_placeholder(&self) {
|
||||
if let Some(source) = self.placeholder.borrow_mut().take() {
|
||||
source.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A load in flight. Dropping it ends the load: the pending loading state is
|
||||
/// cancelled and the content is usable again, unless a newer load has taken
|
||||
/// over in the meantime.
|
||||
pub(crate) struct LoadTicket {
|
||||
loader: Rc<Loader>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl LoadTicket {
|
||||
/// Whether no newer load has started since this one. A superseded load's
|
||||
/// reply is stale and must not be painted.
|
||||
pub(crate) fn is_current(&self) -> bool {
|
||||
self.loader.generation.get() == self.generation
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LoadTicket {
|
||||
fn drop(&mut self) {
|
||||
if self.is_current() {
|
||||
self.loader.cancel_placeholder();
|
||||
self.loader.content.set_sensitive(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Make `model`, a list of [`BoxedAnyObject`]s holding `T`, hold `items`,
|
||||
/// replacing only the run of items that changed, in one `items-changed`
|
||||
/// signal. Rows before and after that run keep their objects, so the views
|
||||
/// keep their widgets, the selection and the scroll position.
|
||||
pub(crate) fn replace_items<T: Clone + PartialEq + 'static>(model: &gio::ListStore, items: &[T]) {
|
||||
let held = |i: usize, item: &T| {
|
||||
model
|
||||
.item(i as u32)
|
||||
.and_downcast::<BoxedAnyObject>()
|
||||
.is_some_and(|object| *object.borrow::<T>() == *item)
|
||||
};
|
||||
let Some((start, removed, added)) = changed_run(model.n_items() as usize, items, held) else {
|
||||
return;
|
||||
};
|
||||
let added: Vec<BoxedAnyObject> = items[added]
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(BoxedAnyObject::new)
|
||||
.collect();
|
||||
model.splice(start as u32, removed as u32, &added);
|
||||
}
|
||||
|
||||
/// The one run that differs between an old list of `old_len` items and `new`:
|
||||
/// where it starts, how many old items it covers, and which new items replace
|
||||
/// them. `held(i, item)` says whether old item `i` equals `item`. `None` when
|
||||
/// the lists are equal.
|
||||
fn changed_run<T>(
|
||||
old_len: usize,
|
||||
new: &[T],
|
||||
held: impl Fn(usize, &T) -> bool,
|
||||
) -> Option<(usize, usize, std::ops::Range<usize>)> {
|
||||
let shared = old_len.min(new.len());
|
||||
let prefix = (0..shared).take_while(|&i| held(i, &new[i])).count();
|
||||
let suffix = (0..shared - prefix)
|
||||
.take_while(|&k| held(old_len - 1 - k, &new[new.len() - 1 - k]))
|
||||
.count();
|
||||
let removed = old_len - prefix - suffix;
|
||||
let added = prefix..new.len() - suffix;
|
||||
(removed > 0 || !added.is_empty()).then_some((prefix, removed, added))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::changed_run;
|
||||
|
||||
fn run(old: &[u32], new: &[u32]) -> Option<(usize, usize, Vec<u32>)> {
|
||||
changed_run(old.len(), new, |i, item| old[i] == *item)
|
||||
.map(|(start, removed, added)| (start, removed, new[added].to_vec()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_lists_change_nothing() {
|
||||
assert_eq!(run(&[1, 2, 3], &[1, 2, 3]), None);
|
||||
assert_eq!(run(&[], &[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_differing_middle_is_replaced() {
|
||||
assert_eq!(run(&[1, 2, 3, 4], &[1, 9, 4]), Some((1, 2, vec![9])));
|
||||
assert_eq!(run(&[1, 2, 3], &[1, 2, 5, 3]), Some((2, 0, vec![5])));
|
||||
assert_eq!(run(&[1, 2, 3], &[1, 3]), Some((1, 1, vec![])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn growing_or_shrinking_at_either_end() {
|
||||
assert_eq!(run(&[1, 2], &[0, 1, 2]), Some((0, 0, vec![0])));
|
||||
assert_eq!(run(&[1, 2], &[1, 2, 3]), Some((2, 0, vec![3])));
|
||||
assert_eq!(run(&[1, 2, 3], &[]), Some((0, 3, vec![])));
|
||||
assert_eq!(run(&[], &[1]), Some((0, 0, vec![1])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_items_are_not_counted_twice() {
|
||||
// The prefix takes both 1s; the suffix must not reuse them.
|
||||
assert_eq!(run(&[1, 1], &[1, 1, 1]), Some((2, 0, vec![1])));
|
||||
assert_eq!(run(&[1, 1, 1], &[1, 1]), Some((2, 1, vec![])));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
//! The app's side of `src/compat.rs`: widgets libadwaita 1.6–1.8 brought, with
|
||||
//! a stand-in for 1.5. [`has_adw_1_8`] picks the real ones at run time.
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// A segmented control: a row of options of which exactly one is on, told
|
||||
/// apart by index in the order they were given. `AdwToggleGroup` on
|
||||
/// libadwaita 1.8, linked toggle buttons before.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ToggleGroup {
|
||||
widget: gtk4::Widget,
|
||||
options: Options,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Options {
|
||||
/// The group's `AdwToggle`s, reached by property name.
|
||||
Toggles(Rc<[glib::Object]>),
|
||||
Buttons(Rc<[gtk4::ToggleButton]>),
|
||||
}
|
||||
|
||||
impl ToggleGroup {
|
||||
/// A group of `(label, tooltip)` options, with the first one on.
|
||||
pub(crate) fn new(options: &[(&str, Option<&str>)]) -> Self {
|
||||
if has_adw_1_8() {
|
||||
let children: String = (0..options.len())
|
||||
.map(|i| format!(r#"<child><object class="AdwToggle" id="toggle{i}"/></child>"#))
|
||||
.collect();
|
||||
let builder = build_ui(&format!(
|
||||
r#"<object class="AdwToggleGroup" id="group">{children}</object>"#
|
||||
));
|
||||
let toggles = options
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (label, tooltip))| {
|
||||
let toggle: glib::Object = built(&builder, &format!("toggle{i}"));
|
||||
toggle.set_property("label", *label);
|
||||
if let Some(tooltip) = tooltip {
|
||||
toggle.set_property("tooltip", *tooltip);
|
||||
}
|
||||
toggle
|
||||
})
|
||||
.collect();
|
||||
let widget: gtk4::Widget = built(&builder, "group");
|
||||
// A toggle group starts with nothing on.
|
||||
widget.set_property("active", 0u32);
|
||||
Self {
|
||||
widget,
|
||||
options: Options::Toggles(toggles),
|
||||
}
|
||||
} else {
|
||||
let bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
bar.add_css_class("linked");
|
||||
let buttons: Rc<[gtk4::ToggleButton]> = options
|
||||
.iter()
|
||||
.map(|(label, tooltip)| {
|
||||
let button = gtk4::ToggleButton::with_label(label);
|
||||
button.set_tooltip_text(*tooltip);
|
||||
bar.append(&button);
|
||||
button
|
||||
})
|
||||
.collect();
|
||||
if let Some((first, rest)) = buttons.split_first() {
|
||||
first.set_active(true);
|
||||
// Chaining each to the first is what GTK turns into mutual
|
||||
// exclusion.
|
||||
for button in rest {
|
||||
button.set_group(Some(first));
|
||||
}
|
||||
}
|
||||
Self {
|
||||
widget: bar.upcast(),
|
||||
options: Options::Buttons(buttons),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The widget to pack.
|
||||
pub(crate) fn widget(&self) -> gtk4::Widget {
|
||||
self.widget.clone()
|
||||
}
|
||||
|
||||
/// Give the options pill-shaped ends.
|
||||
pub(crate) fn set_round(&self) {
|
||||
match &self.options {
|
||||
Options::Toggles(_) => self.widget.add_css_class("round"),
|
||||
Options::Buttons(buttons) => {
|
||||
for button in buttons.iter() {
|
||||
button.add_css_class("pill");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The option that is on.
|
||||
pub(crate) fn active(&self) -> u32 {
|
||||
match &self.options {
|
||||
Options::Toggles(_) => self.widget.property("active"),
|
||||
Options::Buttons(buttons) => {
|
||||
buttons.iter().position(|b| b.is_active()).unwrap_or(0) as u32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn option `index` on, which turns the one that was on off.
|
||||
pub(crate) fn set_active(&self, index: u32) {
|
||||
match &self.options {
|
||||
Options::Toggles(_) => self.widget.set_property("active", index),
|
||||
Options::Buttons(buttons) => {
|
||||
if let Some(button) = buttons.get(index as usize) {
|
||||
button.set_active(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_label(&self, index: u32, label: &str) {
|
||||
match &self.options {
|
||||
Options::Toggles(toggles) => {
|
||||
if let Some(toggle) = toggles.get(index as usize) {
|
||||
toggle.set_property("label", label);
|
||||
}
|
||||
}
|
||||
Options::Buttons(buttons) => {
|
||||
if let Some(button) = buttons.get(index as usize) {
|
||||
button.set_label(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Let option `index` be picked, or not.
|
||||
pub(crate) fn set_enabled(&self, index: u32, enabled: bool) {
|
||||
match &self.options {
|
||||
Options::Toggles(toggles) => {
|
||||
if let Some(toggle) = toggles.get(index as usize) {
|
||||
toggle.set_property("enabled", enabled);
|
||||
}
|
||||
}
|
||||
Options::Buttons(buttons) => {
|
||||
if let Some(button) = buttons.get(index as usize) {
|
||||
button.set_sensitive(enabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Call `f` with the option that has just come on, whether the user or
|
||||
/// [`Self::set_active`] turned it on.
|
||||
pub(crate) fn connect_changed(&self, f: impl Fn(u32) + 'static) {
|
||||
match &self.options {
|
||||
Options::Toggles(_) => {
|
||||
self.widget
|
||||
.connect_notify_local(Some("active"), move |group, _| {
|
||||
f(group.property("active"))
|
||||
});
|
||||
}
|
||||
Options::Buttons(buttons) => {
|
||||
let f = Rc::new(f);
|
||||
for (index, button) in buttons.iter().enumerate() {
|
||||
let f = f.clone();
|
||||
// The group also fires `toggled` for the button going off;
|
||||
// only the one coming on counts.
|
||||
button.connect_toggled(move |button| {
|
||||
if button.is_active() {
|
||||
f(index as u32);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A list row that is a button: a centred title and an icon after it.
|
||||
/// `AdwButtonRow` on libadwaita 1.8, an activatable `adw::ActionRow` before.
|
||||
pub(crate) fn button_row(
|
||||
title: &str,
|
||||
end_icon: &str,
|
||||
on_activated: impl Fn() + 'static,
|
||||
) -> gtk4::ListBoxRow {
|
||||
if has_adw_1_8() {
|
||||
let builder = build_ui(r#"<object class="AdwButtonRow" id="row"/>"#);
|
||||
let row: adw::PreferencesRow = built(&builder, "row");
|
||||
row.set_title(title);
|
||||
row.set_property("end-icon-name", end_icon);
|
||||
row.connect_local("activated", false, move |_| {
|
||||
on_activated();
|
||||
None
|
||||
});
|
||||
row.upcast()
|
||||
} else {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(title)
|
||||
.activatable(true)
|
||||
.build();
|
||||
row.add_suffix(>k4::Image::from_icon_name(end_icon));
|
||||
row.connect_activated(move |_| on_activated());
|
||||
row.upcast()
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the keyboard shortcuts window over `window`. See [`shortcuts_dialog`].
|
||||
pub(crate) fn present_shortcuts(
|
||||
window: &impl IsA<gtk4::Widget>,
|
||||
groups: &[(String, Vec<(&str, String)>)],
|
||||
) {
|
||||
shortcuts_dialog(groups).present(Some(window));
|
||||
}
|
||||
|
||||
/// The keyboard shortcuts window: `groups` of `(accelerator, what it does)`
|
||||
/// under a heading each, all already translated. `AdwShortcutsDialog` on
|
||||
/// libadwaita 1.8, a preferences page of rows before.
|
||||
fn shortcuts_dialog(groups: &[(String, Vec<(&str, String)>)]) -> adw::Dialog {
|
||||
if has_adw_1_8() {
|
||||
let sections: String = groups
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(s, (_, keys))| {
|
||||
let items: String = (0..keys.len())
|
||||
.map(|k| {
|
||||
format!(r#"<child><object class="AdwShortcutsItem" id="item{s}_{k}"/></child>"#)
|
||||
})
|
||||
.collect();
|
||||
format!(
|
||||
r#"<child><object class="AdwShortcutsSection" id="section{s}">{items}</object></child>"#
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let builder = build_ui(&format!(
|
||||
r#"<object class="AdwShortcutsDialog" id="dialog">{sections}</object>"#
|
||||
));
|
||||
for (s, (title, keys)) in groups.iter().enumerate() {
|
||||
let section: glib::Object = built(&builder, &format!("section{s}"));
|
||||
section.set_property("title", title.as_str());
|
||||
for (k, (accel, action)) in keys.iter().enumerate() {
|
||||
let item: glib::Object = built(&builder, &format!("item{s}_{k}"));
|
||||
item.set_property("title", action.as_str());
|
||||
item.set_property("accelerator", *accel);
|
||||
}
|
||||
}
|
||||
return built(&builder, "dialog");
|
||||
}
|
||||
|
||||
let page = adw::PreferencesPage::new();
|
||||
for (title, keys) in groups {
|
||||
let group = adw::PreferencesGroup::builder().title(title).build();
|
||||
for (accel, action) in keys {
|
||||
let row = adw::ActionRow::builder().title(action).build();
|
||||
row.add_suffix(
|
||||
>k4::ShortcutLabel::builder()
|
||||
.accelerator(*accel)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build(),
|
||||
);
|
||||
group.add(&row);
|
||||
}
|
||||
page.add(&group);
|
||||
}
|
||||
|
||||
adw::Dialog::builder()
|
||||
.title(gettext("Keyboard Shortcuts"))
|
||||
.content_width(460)
|
||||
.content_height(620)
|
||||
.child(&{
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&adw::HeaderBar::new());
|
||||
toolbar.set_content(Some(&page));
|
||||
toolbar
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The newer widgets are made by class name and set up by property and
|
||||
/// signal name, so a typo in any of those only shows when they are built.
|
||||
/// Needs a display and libadwaita 1.8; passes trivially without either.
|
||||
#[test]
|
||||
fn newer_widgets_build_by_class_name() {
|
||||
if adw::init().is_err() || !has_adw_1_8() {
|
||||
return;
|
||||
}
|
||||
|
||||
let group = ToggleGroup::new(&[("One", None), ("Two", Some("The second"))]);
|
||||
group.set_round();
|
||||
group.set_label(1, "Deux");
|
||||
group.set_enabled(1, true);
|
||||
assert_eq!(group.widget().type_().name(), "AdwToggleGroup");
|
||||
assert_eq!(group.active(), 0);
|
||||
let changed = Rc::new(Cell::new(None));
|
||||
group.connect_changed({
|
||||
let changed = changed.clone();
|
||||
move |index| changed.set(Some(index))
|
||||
});
|
||||
group.set_active(1);
|
||||
assert_eq!((group.active(), changed.get()), (1, Some(1)));
|
||||
|
||||
let row = button_row("Row", "go-down-symbolic", || {});
|
||||
assert_eq!(row.type_().name(), "AdwButtonRow");
|
||||
assert_eq!(spinner().type_().name(), "AdwSpinner");
|
||||
|
||||
let dialog = shortcuts_dialog(&[(
|
||||
"General".to_owned(),
|
||||
vec![("<Control>q", "Quit".to_owned())],
|
||||
)]);
|
||||
assert_eq!(dialog.type_().name(), "AdwShortcutsDialog");
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ pub(crate) struct DetailsWidgets {
|
||||
/// [`DirEntry`] and [`wire_details`] connects the buttons.
|
||||
pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
let toggle = gtk4::ToggleButton::builder()
|
||||
.icon_name("sidebar-show-right-symbolic")
|
||||
.icon_name("pdfs-details-pane-symbolic")
|
||||
.tooltip_text(gettext("Details (Alt+Enter)"))
|
||||
.build();
|
||||
|
||||
@@ -202,7 +202,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
let toggle_off = toggle.clone();
|
||||
empty_close.connect_clicked(move |_| toggle_off.set_active(false));
|
||||
let empty_status = adw::StatusPage::builder()
|
||||
.icon_name("sidebar-show-right-symbolic")
|
||||
.icon_name("pdfs-details-pane-symbolic")
|
||||
.title(gettext("No item selected"))
|
||||
.description(gettext("Select a file or folder to see its details."))
|
||||
.vexpand(true)
|
||||
@@ -373,7 +373,7 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
});
|
||||
d.size_row.set_visible(!entry.is_dir);
|
||||
d.modified_row
|
||||
.set_subtitle(&format_modified(entry.modified));
|
||||
.set_subtitle(&dates::short_date(entry.modified));
|
||||
let rel = entry_rel(ui, entry);
|
||||
let parent = match rel.rfind('/') {
|
||||
Some(i) => &rel[..i],
|
||||
@@ -494,13 +494,13 @@ pub(crate) fn versions_summary(items: &[RevisionInfo]) -> String {
|
||||
match (items.len(), latest) {
|
||||
(0, _) | (_, None) => gettext("No earlier versions"),
|
||||
// Translators: {date} is the date of the only version.
|
||||
(1, Some(t)) => gettext_f("1 version · {date}", &[("date", &format_modified(t))]),
|
||||
(1, Some(t)) => gettext_f("1 version · {date}", &[("date", &dates::short_date(t))]),
|
||||
// Translators: {date} is the date of the newest version.
|
||||
(n, Some(t)) => ngettext_f(
|
||||
"{n} version · latest {date}",
|
||||
"{n} versions · latest {date}",
|
||||
n as u64,
|
||||
&[("date", &format_modified(t))],
|
||||
&[("date", &dates::short_date(t))],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
//! The file list every file page shows: an icon grid and a column list over one
|
||||
//! model and one selection, with thumbnails, context menus, rubberband selection
|
||||
//! and click-to-deselect. My files, Trash, Shared with me and Shared by me build
|
||||
//! one each and differ only in their columns, menus and what activating an entry
|
||||
//! does (see [`FileListBehavior`]).
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// Where a page's entries go when the user acts on them. Plain function pointers:
|
||||
/// each page has one of these, fixed for the life of the window.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct FileListBehavior {
|
||||
/// Double-click or Enter on an entry.
|
||||
pub(crate) activate: fn(&Rc<Ui>, &DirEntry),
|
||||
/// The right-click menu for one entry.
|
||||
pub(crate) entry_menu: fn(&Rc<Ui>, &DirEntry) -> ActionMenu,
|
||||
/// The right-click menu for a multi-selection the clicked entry is part of.
|
||||
pub(crate) bulk_menu: fn(&Rc<Ui>, Vec<DirEntry>) -> ActionMenu,
|
||||
/// The right-click menu on empty space.
|
||||
pub(crate) background_menu: fn(&Rc<Ui>) -> ActionMenu,
|
||||
/// Paint the offline/cached/online-only badge. Only entries in the mounted
|
||||
/// tree have a local state worth showing.
|
||||
pub(crate) badges: bool,
|
||||
/// Entries can be dragged onto folders to move them.
|
||||
pub(crate) drag_and_drop: bool,
|
||||
}
|
||||
|
||||
/// A realised grid cell's thumbnail and name label.
|
||||
type GridTile = (glib::WeakRef<gtk4::Overlay>, glib::WeakRef<gtk4::Label>);
|
||||
|
||||
/// One page's grid and list. Cheap to clone: every field is a GObject or an
|
||||
/// `Rc`, so a clone is the same list.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct FileList {
|
||||
/// The entries, as [`BoxedAnyObject`]-wrapped [`DirEntry`]s.
|
||||
pub(crate) model: gio::ListStore,
|
||||
/// One selection for both views: switching layout keeps what is selected.
|
||||
pub(crate) selection: gtk4::MultiSelection,
|
||||
pub(crate) grid: gtk4::GridView,
|
||||
pub(crate) column_view: gtk4::ColumnView,
|
||||
/// The two views, named "grid" and "list".
|
||||
pub(crate) views: gtk4::Stack,
|
||||
thumbnail_size: Rc<Cell<i32>>,
|
||||
/// Weak references to realised grid cells. A new thumbnail size resizes only
|
||||
/// these visible, recycled surfaces instead of invalidating the whole model.
|
||||
tiles: Rc<RefCell<Vec<GridTile>>>,
|
||||
behavior: Rc<Cell<Option<FileListBehavior>>>,
|
||||
}
|
||||
|
||||
impl FileList {
|
||||
/// Assemble the empty views. The factories need the [`Ui`] handle, so they
|
||||
/// are installed later by [`FileList::wire`].
|
||||
pub(crate) fn new() -> Self {
|
||||
let model = gio::ListStore::new::<BoxedAnyObject>();
|
||||
// Multi-select: acting on a batch is the common case, and doing it one
|
||||
// confirmation dialog at a time is not a workflow. Rubberband drags a
|
||||
// selection box from empty space, as a file manager does.
|
||||
let selection = gtk4::MultiSelection::new(Some(model.clone()));
|
||||
let grid = gtk4::GridView::builder()
|
||||
.model(&selection)
|
||||
.min_columns(2)
|
||||
.max_columns(32)
|
||||
.enable_rubberband(true)
|
||||
.build();
|
||||
grid.add_css_class("file-grid");
|
||||
let grid_scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.child(&grid)
|
||||
.build();
|
||||
|
||||
let column_view = gtk4::ColumnView::builder()
|
||||
.model(&selection)
|
||||
.enable_rubberband(true)
|
||||
.build();
|
||||
column_view.add_css_class("data-table");
|
||||
let column_scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.child(&column_view)
|
||||
.build();
|
||||
|
||||
let views = gtk4::Stack::new();
|
||||
views.set_vexpand(true);
|
||||
views.add_named(&grid_scroll, Some("grid"));
|
||||
views.add_named(&column_scroll, Some("list"));
|
||||
|
||||
FileList {
|
||||
model,
|
||||
selection,
|
||||
grid,
|
||||
column_view,
|
||||
views,
|
||||
thumbnail_size: Rc::new(Cell::new(GRID_THUMB_DEFAULT)),
|
||||
tiles: Rc::new(RefCell::new(Vec::new())),
|
||||
behavior: Rc::new(Cell::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the grid tiles, the Name column, activation, the context menus
|
||||
/// and click-to-deselect. The page appends its other columns afterwards.
|
||||
pub(crate) fn wire(&self, ui: &Rc<Ui>, behavior: FileListBehavior) {
|
||||
self.behavior.set(Some(behavior));
|
||||
for view in [
|
||||
self.grid.clone().upcast::<gtk4::Widget>(),
|
||||
self.column_view.clone().upcast(),
|
||||
] {
|
||||
self.attach_background_menu(ui, &view, behavior);
|
||||
self.attach_background_deselect(&view);
|
||||
}
|
||||
|
||||
let factory = gtk4::SignalListItemFactory::new();
|
||||
factory.connect_setup({
|
||||
let (ui, list) = (ui.clone(), self.clone());
|
||||
move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let size = list.thumbnail_size.get();
|
||||
let thumbnail = file_thumbnail_widget(size, grid_fallback_size(size));
|
||||
// Keep the sync-state badge inside the thumbnail surface.
|
||||
let badge = gtk4::Image::builder()
|
||||
.pixel_size(18)
|
||||
.halign(gtk4::Align::End)
|
||||
.valign(gtk4::Align::Start)
|
||||
.margin_top(2)
|
||||
.margin_end(2)
|
||||
.visible(false)
|
||||
.build();
|
||||
badge.add_css_class("file-badge");
|
||||
thumbnail.add_overlay(&badge);
|
||||
// `WordChar` rather than the default `Word`: a name with no
|
||||
// spaces offers no word-break opportunity, so word wrapping
|
||||
// cannot break it at all and the label asks for its full natural
|
||||
// width instead — one tile stretches to the width of the window
|
||||
// and the grid collapses to a single column. Allowing a mid-word
|
||||
// break is what keeps the two-line-then-ellipsis budget below
|
||||
// enforceable for *every* name rather than only the ones that
|
||||
// happen to have spaces.
|
||||
let label = gtk4::Label::builder()
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.justify(gtk4::Justification::Center)
|
||||
.max_width_chars(13)
|
||||
.width_chars(13)
|
||||
.wrap(true)
|
||||
.wrap_mode(gtk4::pango::WrapMode::WordChar)
|
||||
.lines(2)
|
||||
.build();
|
||||
list.tiles
|
||||
.borrow_mut()
|
||||
.push((thumbnail.downgrade(), label.downgrade()));
|
||||
let tile = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||
tile.add_css_class("file-tile");
|
||||
tile.append(&thumbnail);
|
||||
tile.append(&label);
|
||||
list.attach_context_menu(&ui, item, &tile, behavior);
|
||||
if behavior.drag_and_drop {
|
||||
attach_drag(&ui, item, &tile);
|
||||
attach_drop(&ui, item, &tile);
|
||||
}
|
||||
item.set_child(Some(&tile));
|
||||
}
|
||||
});
|
||||
factory.connect_bind({
|
||||
let (ui, list) = (ui.clone(), self.clone());
|
||||
move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let tile = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||
let thumbnail = tile.first_child().and_downcast::<gtk4::Overlay>().unwrap();
|
||||
let badge = thumbnail
|
||||
.last_child()
|
||||
.and_downcast::<gtk4::Image>()
|
||||
.unwrap();
|
||||
let label = thumbnail
|
||||
.next_sibling()
|
||||
.and_downcast::<gtk4::Label>()
|
||||
.unwrap();
|
||||
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
||||
let entry = obj.borrow::<DirEntry>();
|
||||
resize_grid_tile(&thumbnail, &label, list.thumbnail_size.get());
|
||||
bind_file_thumbnail(&ui, &thumbnail, &entry, false);
|
||||
label.set_label(&entry.name);
|
||||
label.set_tooltip_text(Some(&entry.name));
|
||||
if behavior.badges {
|
||||
apply_badge(&badge, &entry);
|
||||
}
|
||||
}
|
||||
});
|
||||
self.grid.set_factory(Some(&factory));
|
||||
|
||||
let ui_grid = ui.clone();
|
||||
self.grid.connect_activate(move |grid, pos| {
|
||||
if let Some(entry) = entry_at(grid.model().as_ref(), pos) {
|
||||
(behavior.activate)(&ui_grid, &entry);
|
||||
}
|
||||
});
|
||||
|
||||
self.column_view
|
||||
.append_column(&self.name_column(ui, behavior));
|
||||
let ui_col = ui.clone();
|
||||
self.column_view.connect_activate(move |view, pos| {
|
||||
if let Some(entry) = entry_at(view.model().as_ref(), pos) {
|
||||
(behavior.activate)(&ui_col, &entry);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Every highlighted entry, in model order.
|
||||
///
|
||||
/// Walks the model rather than the selection bitset: a listing is at most a
|
||||
/// few thousand rows, and asking each position whether it is selected keeps
|
||||
/// this free of bitset-iterator lifetimes for no measurable cost.
|
||||
pub(crate) fn selected(&self) -> Vec<DirEntry> {
|
||||
(0..self.selection.n_items())
|
||||
.filter(|i| self.selection.is_selected(*i))
|
||||
.filter_map(|i| entry_at(Some(&self.selection), i))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Show the grid, or the list.
|
||||
pub(crate) fn show_list(&self, list: bool) {
|
||||
self.views
|
||||
.set_visible_child_name(if list { "list" } else { "grid" });
|
||||
}
|
||||
|
||||
/// Whether the list, rather than the grid, is on screen.
|
||||
pub(crate) fn shows_list(&self) -> bool {
|
||||
self.views.visible_child_name().as_deref() == Some("list")
|
||||
}
|
||||
|
||||
/// Resize the grid's thumbnails. Only realised cells are touched; the rest
|
||||
/// pick the size up when they are bound.
|
||||
pub(crate) fn set_thumbnail_size(&self, size: i32) {
|
||||
if self.thumbnail_size.replace(size) == size {
|
||||
return;
|
||||
}
|
||||
self.tiles
|
||||
.borrow_mut()
|
||||
.retain(|(thumbnail_ref, label_ref)| {
|
||||
let (Some(thumbnail), Some(label)) = (thumbnail_ref.upgrade(), label_ref.upgrade())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
resize_grid_tile(&thumbnail, &label, size);
|
||||
true
|
||||
});
|
||||
self.grid.queue_resize();
|
||||
}
|
||||
|
||||
/// The Menu key or Shift+F10: the menu for the selection, or the background
|
||||
/// menu when nothing is selected, opened at the focused item.
|
||||
pub(crate) fn popup_keyboard_menu(&self, ui: &Rc<Ui>) {
|
||||
let Some(behavior) = self.behavior.get() else {
|
||||
return;
|
||||
};
|
||||
let view = self
|
||||
.views
|
||||
.visible_child()
|
||||
.unwrap_or_else(|| self.views.clone().upcast());
|
||||
// The focused cell is where the eye is; without one, the middle of the view.
|
||||
let (x, y) = view
|
||||
.root()
|
||||
.and_then(|root| root.focus())
|
||||
.filter(|focus| focus.is_ancestor(&view))
|
||||
.and_then(|focus| focus.compute_bounds(&view))
|
||||
.map(|b| {
|
||||
(
|
||||
(b.x() + b.width() / 2.0) as f64,
|
||||
(b.y() + b.height() / 2.0) as f64,
|
||||
)
|
||||
})
|
||||
.unwrap_or((view.width() as f64 / 2.0, view.height() as f64 / 2.0));
|
||||
let selected = self.selected();
|
||||
let menu = match selected.len() {
|
||||
0 => (behavior.background_menu)(ui),
|
||||
1 => (behavior.entry_menu)(ui, &selected[0]),
|
||||
_ => (behavior.bulk_menu)(ui, selected),
|
||||
};
|
||||
menu.popup_at(&view, x, y);
|
||||
}
|
||||
|
||||
/// The Name column: a small thumbnail with its local-state badge overlaid,
|
||||
/// followed by the name and the same right-click menu the grid tiles carry.
|
||||
fn name_column(&self, ui: &Rc<Ui>, behavior: FileListBehavior) -> gtk4::ColumnViewColumn {
|
||||
let factory = gtk4::SignalListItemFactory::new();
|
||||
factory.connect_setup({
|
||||
let (ui, list) = (ui.clone(), self.clone());
|
||||
move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let thumbnail = file_thumbnail_widget(28, 16);
|
||||
let badge = gtk4::Image::builder()
|
||||
.pixel_size(14)
|
||||
.halign(gtk4::Align::End)
|
||||
.valign(gtk4::Align::Start)
|
||||
.visible(false)
|
||||
.build();
|
||||
badge.add_css_class("file-badge");
|
||||
thumbnail.add_overlay(&badge);
|
||||
// Ellipsized so the Name column can be *narrower* than its
|
||||
// longest name. Without it the label's minimum width is the whole
|
||||
// string, the column inherits that minimum, and one long name
|
||||
// pushes the other columns off the right edge of the window for
|
||||
// every row.
|
||||
let label = gtk4::Label::builder()
|
||||
.halign(gtk4::Align::Start)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
let cell = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
cell.append(&thumbnail);
|
||||
cell.append(&label);
|
||||
list.attach_context_menu(&ui, item, &cell, behavior);
|
||||
if behavior.drag_and_drop {
|
||||
attach_drag(&ui, item, &cell);
|
||||
attach_drop(&ui, item, &cell);
|
||||
}
|
||||
item.set_child(Some(&cell));
|
||||
}
|
||||
});
|
||||
factory.connect_bind({
|
||||
let ui = ui.clone();
|
||||
move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let cell = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||
let thumbnail = cell.first_child().and_downcast::<gtk4::Overlay>().unwrap();
|
||||
let badge = thumbnail
|
||||
.last_child()
|
||||
.and_downcast::<gtk4::Image>()
|
||||
.unwrap();
|
||||
let label = thumbnail
|
||||
.next_sibling()
|
||||
.and_downcast::<gtk4::Label>()
|
||||
.unwrap();
|
||||
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
||||
let entry = obj.borrow::<DirEntry>();
|
||||
bind_file_thumbnail(&ui, &thumbnail, &entry, true);
|
||||
label.set_label(&entry.name);
|
||||
label.set_tooltip_text(Some(&entry.name));
|
||||
if behavior.badges {
|
||||
apply_badge(&badge, &entry);
|
||||
}
|
||||
}
|
||||
});
|
||||
let column = gtk4::ColumnViewColumn::new(Some(&pgettext("column", "Name")), Some(factory));
|
||||
column.set_expand(true);
|
||||
column
|
||||
}
|
||||
|
||||
/// Pop the menu for whatever entry `item` is bound to when a cell is
|
||||
/// right-clicked. Capturing the [`gtk4::ListItem`] rather than a snapshot of
|
||||
/// the entry keeps the menu right as the view recycles cells while
|
||||
/// scrolling. The click is claimed, so the background menu does not open on
|
||||
/// top of it.
|
||||
fn attach_context_menu(
|
||||
&self,
|
||||
ui: &Rc<Ui>,
|
||||
item: >k4::ListItem,
|
||||
anchor: >k4::Box,
|
||||
behavior: FileListBehavior,
|
||||
) {
|
||||
let gesture = gtk4::GestureClick::new();
|
||||
gesture.set_button(gtk4::gdk::BUTTON_SECONDARY);
|
||||
let (ui, list, item, target) = (ui.clone(), self.clone(), item.clone(), anchor.clone());
|
||||
gesture.connect_pressed(move |gesture, _, x, y| {
|
||||
gesture.set_state(gtk4::EventSequenceState::Claimed);
|
||||
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>() {
|
||||
let entry = obj.borrow::<DirEntry>().clone();
|
||||
// Right-clicking a row that is part of a multi-selection acts on
|
||||
// the batch; right-clicking outside one acts on the row.
|
||||
let selected = list.selected();
|
||||
if selected.len() > 1 && selected.iter().any(|e| e.uid == entry.uid) {
|
||||
(behavior.bulk_menu)(&ui, selected).popup_at(&target, x, y);
|
||||
} else {
|
||||
(behavior.entry_menu)(&ui, &entry).popup_at(&target, x, y);
|
||||
}
|
||||
}
|
||||
});
|
||||
anchor.add_controller(gesture);
|
||||
}
|
||||
|
||||
/// Right-clicking the empty space of a view offers the page's own actions.
|
||||
fn attach_background_menu(&self, ui: &Rc<Ui>, view: >k4::Widget, behavior: FileListBehavior) {
|
||||
let gesture = gtk4::GestureClick::new();
|
||||
gesture.set_button(gtk4::gdk::BUTTON_SECONDARY);
|
||||
let (ui, target) = (ui.clone(), view.clone());
|
||||
gesture.connect_pressed(move |_, _, x, y| {
|
||||
(behavior.background_menu)(&ui).popup_at(&target, x, y);
|
||||
});
|
||||
view.add_controller(gesture);
|
||||
}
|
||||
|
||||
/// Clicking the empty space of a view drops the selection, as in a file
|
||||
/// manager. GTK's list views leave it alone, so a stray highlight otherwise
|
||||
/// sticks around until another item is clicked.
|
||||
fn attach_background_deselect(&self, view: >k4::Widget) {
|
||||
let gesture = gtk4::GestureClick::new();
|
||||
gesture.set_button(gtk4::gdk::BUTTON_PRIMARY);
|
||||
// Capture sees the press before an item's own gesture claims it; this
|
||||
// only looks, so the item still gets its click.
|
||||
gesture.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
||||
let (selection, target) = (self.selection.clone(), view.clone());
|
||||
gesture.connect_pressed(move |gesture, _, x, y| {
|
||||
let modifiers = gesture.current_event_state();
|
||||
if modifiers.intersects(
|
||||
gtk4::gdk::ModifierType::CONTROL_MASK | gtk4::gdk::ModifierType::SHIFT_MASK,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if !on_item(&target, x, y) {
|
||||
selection.unselect_all();
|
||||
}
|
||||
});
|
||||
view.add_controller(gesture);
|
||||
}
|
||||
}
|
||||
|
||||
/// A header button that switches `list` between grid and list. It offers the
|
||||
/// other layout, the way a toggle reads.
|
||||
pub(crate) fn layout_button(list: &FileList) -> gtk4::Button {
|
||||
let button = gtk4::Button::builder().valign(gtk4::Align::Center).build();
|
||||
let sync = |button: >k4::Button, list: bool| {
|
||||
button.set_icon_name(if list {
|
||||
"view-grid-symbolic"
|
||||
} else {
|
||||
"view-list-symbolic"
|
||||
});
|
||||
button.set_tooltip_text(Some(&if list {
|
||||
gettext("Show as grid (Ctrl+1)")
|
||||
} else {
|
||||
gettext("Show as list (Ctrl+2)")
|
||||
}));
|
||||
};
|
||||
sync(&button, list.shows_list());
|
||||
let weak = button.downgrade();
|
||||
list.views.connect_visible_child_name_notify(move |views| {
|
||||
if let Some(button) = weak.upgrade() {
|
||||
sync(
|
||||
&button,
|
||||
views.visible_child_name().as_deref() == Some("list"),
|
||||
);
|
||||
}
|
||||
});
|
||||
let views = list.views.clone();
|
||||
button.connect_clicked(move |_| {
|
||||
let to_list = views.visible_child_name().as_deref() != Some("list");
|
||||
views.set_visible_child_name(if to_list { "list" } else { "grid" });
|
||||
});
|
||||
button
|
||||
}
|
||||
|
||||
/// Build a trailing text column whose cell text is derived from each [`DirEntry`]
|
||||
/// by `render`.
|
||||
pub(crate) fn text_column(
|
||||
title: &str,
|
||||
render: impl Fn(&DirEntry) -> String + 'static,
|
||||
) -> gtk4::ColumnViewColumn {
|
||||
let factory = gtk4::SignalListItemFactory::new();
|
||||
factory.connect_setup(|_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let label = gtk4::Label::builder()
|
||||
.halign(gtk4::Align::Start)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
label.add_css_class("dim-label");
|
||||
item.set_child(Some(&label));
|
||||
});
|
||||
factory.connect_bind(move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let label = item.child().and_downcast::<gtk4::Label>().unwrap();
|
||||
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
||||
let entry = obj.borrow::<DirEntry>();
|
||||
let text = render(&entry);
|
||||
label.set_tooltip_text(Some(&text));
|
||||
label.set_label(&text);
|
||||
});
|
||||
gtk4::ColumnViewColumn::new(Some(title), Some(factory))
|
||||
}
|
||||
|
||||
fn resize_grid_tile(thumbnail: >k4::Overlay, label: >k4::Label, size: i32) {
|
||||
resize_file_thumbnail(thumbnail, size, grid_fallback_size(size));
|
||||
let name_width = (size / 6 + 1).clamp(8, 24);
|
||||
label.set_width_chars(name_width);
|
||||
label.set_max_width_chars(name_width);
|
||||
}
|
||||
|
||||
fn grid_fallback_size(thumbnail_size: i32) -> i32 {
|
||||
(thumbnail_size * 8 / 9).clamp(24, thumbnail_size)
|
||||
}
|
||||
|
||||
/// Whether `(x, y)` in `view` lands on an item: a grid tile ("child"), a list
|
||||
/// row ("row"), or a column header ("header"), which has clicks of its own.
|
||||
fn on_item(view: >k4::Widget, x: f64, y: f64) -> bool {
|
||||
let mut widget = view.pick(x, y, gtk4::PickFlags::DEFAULT);
|
||||
while let Some(w) = widget {
|
||||
if &w == view {
|
||||
return false;
|
||||
}
|
||||
if matches!(w.css_name().as_str(), "child" | "row" | "header") {
|
||||
return true;
|
||||
}
|
||||
widget = w.parent();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Every page's file list.
|
||||
pub(crate) fn file_lists(ui: &Ui) -> [&FileList; 4] {
|
||||
[
|
||||
&ui.browser.files,
|
||||
&ui.trash.files,
|
||||
&ui.shared.files,
|
||||
&ui.shared_by_me.files,
|
||||
]
|
||||
}
|
||||
|
||||
/// The file list of the page on screen, if it has one.
|
||||
pub(crate) fn visible_file_list(ui: &Ui) -> Option<&FileList> {
|
||||
match ui.stack.visible_child_name()?.as_str() {
|
||||
"browser" => Some(&ui.browser.files),
|
||||
"trash" => Some(&ui.trash.files),
|
||||
"sharedbyme" => Some(&ui.shared_by_me.files),
|
||||
"shared" if ui.shared.views.visible_child_name().as_deref() == Some("files") => {
|
||||
Some(&ui.shared.files)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
use crate::*;
|
||||
|
||||
/// An action run by an [`ActionMenu`] item.
|
||||
pub(crate) type MenuAction = Box<dyn Fn()>;
|
||||
|
||||
/// A `gio::Menu` whose items run closures, for menus whose contents depend on
|
||||
/// what they were opened for (a row, a selection, a photo). Each item gets its
|
||||
/// own action in a private `menu` group, installed on whatever hosts the
|
||||
@@ -148,12 +145,3 @@ fn popover_host(anchor: >k4::Widget) -> gtk4::Widget {
|
||||
}
|
||||
host
|
||||
}
|
||||
|
||||
/// A flat ⋮ button whose menu lists `items` as (label, action).
|
||||
pub(crate) fn more_menu_button(items: Vec<(&str, MenuAction)>) -> gtk4::MenuButton {
|
||||
let mut menu = ActionMenu::new();
|
||||
for (label, run) in items {
|
||||
menu.item(label, run);
|
||||
}
|
||||
menu.button()
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
pub(crate) mod compat;
|
||||
pub(crate) mod details;
|
||||
pub(crate) mod file_list;
|
||||
pub(crate) mod menu;
|
||||
pub(crate) mod scrubber;
|
||||
pub(crate) mod share_dialog;
|
||||
pub(crate) mod thumbnails;
|
||||
pub(crate) mod versions_dialog;
|
||||
@@ -0,0 +1,403 @@
|
||||
//! The Photos timeline's month scrubber, laid over the grid's right edge as in
|
||||
//! Google Photos: a thin tick where each year starts, a knob at the month on
|
||||
//! screen, and a bubble naming the month under the pointer. It stays out of
|
||||
//! the way until the pointer nears the edge or the timeline scrolls.
|
||||
|
||||
use crate::*;
|
||||
use gtk4::cairo;
|
||||
|
||||
/// Width of the strip the scrubber draws in, in px.
|
||||
const STRIP_WIDTH: i32 = 72;
|
||||
/// How close to the right edge the pointer has to come to show the scrubber.
|
||||
const EDGE: f64 = 96.0;
|
||||
/// Space kept free above the first and below the last month, in px.
|
||||
const PAD: f64 = 16.0;
|
||||
/// How long the scrubber stays up after the timeline stops scrolling.
|
||||
const LINGER: Duration = Duration::from_millis(1500);
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
/// How many months the strip spans, newest at the top.
|
||||
months: usize,
|
||||
/// Where each year starts, as `(month index, label)`.
|
||||
years: Vec<(usize, String)>,
|
||||
/// The month the knob marks.
|
||||
value: usize,
|
||||
/// Whether the timeline has months to move between at all.
|
||||
available: bool,
|
||||
/// The pointer is near the right edge.
|
||||
near: bool,
|
||||
/// The month under the pointer while it is on the strip.
|
||||
hover: Option<usize>,
|
||||
/// Where the running drag started, in the strip's coordinates.
|
||||
drag_from: Option<f64>,
|
||||
/// Keeps the scrubber up for a moment after a scroll.
|
||||
linger: Option<glib::SourceId>,
|
||||
label: Option<Rc<dyn Fn(usize) -> String>>,
|
||||
on_pick: Option<Rc<dyn Fn(usize)>>,
|
||||
}
|
||||
|
||||
/// The scrubber's widgets and state. Cheap to clone: every clone is the same
|
||||
/// scrubber.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Scrubber {
|
||||
revealer: gtk4::Revealer,
|
||||
area: gtk4::DrawingArea,
|
||||
knob: gtk4::Box,
|
||||
bubble: gtk4::Label,
|
||||
/// The timeline the scrubber lies over.
|
||||
scroll: Option<gtk4::ScrolledWindow>,
|
||||
state: Rc<RefCell<State>>,
|
||||
}
|
||||
|
||||
impl Scrubber {
|
||||
/// Build the scrubber over `host`, whose child is the scrolled timeline.
|
||||
pub(crate) fn new(host: >k4::Overlay) -> Self {
|
||||
let scroll = host.child().and_downcast::<gtk4::ScrolledWindow>();
|
||||
let area = gtk4::DrawingArea::builder()
|
||||
.width_request(STRIP_WIDTH)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
area.add_css_class("photo-scrubber");
|
||||
let knob = gtk4::Box::builder()
|
||||
.width_request(24)
|
||||
.height_request(4)
|
||||
.halign(gtk4::Align::End)
|
||||
.valign(gtk4::Align::Start)
|
||||
.margin_end(4)
|
||||
.can_target(false)
|
||||
.build();
|
||||
knob.add_css_class("photo-scrubber-knob");
|
||||
let strip = gtk4::Overlay::new();
|
||||
strip.set_child(Some(&area));
|
||||
strip.add_overlay(&knob);
|
||||
let revealer = gtk4::Revealer::builder()
|
||||
.transition_type(gtk4::RevealerTransitionType::Crossfade)
|
||||
.transition_duration(150)
|
||||
.halign(gtk4::Align::End)
|
||||
.valign(gtk4::Align::Fill)
|
||||
.can_target(false)
|
||||
.child(&strip)
|
||||
.build();
|
||||
let bubble = gtk4::Label::builder()
|
||||
.halign(gtk4::Align::End)
|
||||
.valign(gtk4::Align::Start)
|
||||
.margin_end(STRIP_WIDTH)
|
||||
.can_target(false)
|
||||
.visible(false)
|
||||
.build();
|
||||
bubble.add_css_class("photo-scrubber-bubble");
|
||||
host.add_overlay(&revealer);
|
||||
host.add_overlay(&bubble);
|
||||
|
||||
let scrubber = Self {
|
||||
revealer,
|
||||
area,
|
||||
knob,
|
||||
bubble,
|
||||
scroll,
|
||||
state: Rc::default(),
|
||||
};
|
||||
scrubber.wire(host);
|
||||
scrubber
|
||||
}
|
||||
|
||||
fn wire(&self, host: >k4::Overlay) {
|
||||
let this = self.clone();
|
||||
self.area.set_draw_func(move |area, cr, width, height| {
|
||||
this.draw(area, cr, width, height);
|
||||
});
|
||||
let this = self.clone();
|
||||
self.area.connect_resize(move |_, _, _| this.place_knob());
|
||||
|
||||
// Near the edge is judged on the whole timeline, not the strip: the
|
||||
// strip takes no input while it is hidden.
|
||||
let edge = gtk4::EventControllerMotion::new();
|
||||
let this = self.clone();
|
||||
let host_motion = host.clone();
|
||||
edge.connect_motion(move |_, x, _| {
|
||||
let near = x >= f64::from(host_motion.width()) - EDGE;
|
||||
if this.state.borrow().near != near {
|
||||
this.state.borrow_mut().near = near;
|
||||
this.sync_reveal();
|
||||
}
|
||||
});
|
||||
let this = self.clone();
|
||||
edge.connect_leave(move |_| {
|
||||
this.state.borrow_mut().near = false;
|
||||
this.sync_reveal();
|
||||
});
|
||||
host.add_controller(edge);
|
||||
|
||||
let hover = gtk4::EventControllerMotion::new();
|
||||
let this = self.clone();
|
||||
hover.connect_motion(move |_, _, y| {
|
||||
let index = this.index_at(y);
|
||||
this.state.borrow_mut().hover = index;
|
||||
this.sync_bubble();
|
||||
});
|
||||
let this = self.clone();
|
||||
hover.connect_leave(move |_| {
|
||||
this.state.borrow_mut().hover = None;
|
||||
this.sync_bubble();
|
||||
});
|
||||
self.area.add_controller(hover);
|
||||
|
||||
// A click is a drag that goes nowhere, so this covers both.
|
||||
let drag = gtk4::GestureDrag::new();
|
||||
let this = self.clone();
|
||||
drag.connect_drag_begin(move |_, _, y| {
|
||||
this.state.borrow_mut().drag_from = Some(y);
|
||||
this.pick(y, true);
|
||||
});
|
||||
let this = self.clone();
|
||||
drag.connect_drag_update(move |_, _, dy| {
|
||||
let from = this.state.borrow().drag_from;
|
||||
if let Some(from) = from {
|
||||
this.pick(from + dy, false);
|
||||
}
|
||||
});
|
||||
let this = self.clone();
|
||||
drag.connect_drag_end(move |_, _, _| {
|
||||
this.state.borrow_mut().drag_from = None;
|
||||
this.sync_reveal();
|
||||
this.sync_bubble();
|
||||
});
|
||||
self.area.add_controller(drag);
|
||||
}
|
||||
|
||||
/// Span `months` months, newest first, with a tick and a label where each
|
||||
/// of `years` starts. Moves the knob back to the top.
|
||||
pub(crate) fn set_months(&self, months: usize, years: Vec<(usize, String)>) {
|
||||
{
|
||||
let mut state = self.state.borrow_mut();
|
||||
state.months = months;
|
||||
state.years = years;
|
||||
state.value = 0;
|
||||
}
|
||||
self.area.queue_draw();
|
||||
self.place_knob();
|
||||
}
|
||||
|
||||
/// Whether there is anything to scrub through. The scrubber never shows
|
||||
/// while there is not.
|
||||
pub(crate) fn set_available(&self, available: bool) {
|
||||
self.state.borrow_mut().available = available;
|
||||
// The scrubber takes the scrollbar's place at the edge; both at once
|
||||
// would fight over the same strip.
|
||||
if let Some(scroll) = &self.scroll {
|
||||
scroll.set_vscrollbar_policy(if available {
|
||||
gtk4::PolicyType::External
|
||||
} else {
|
||||
gtk4::PolicyType::Automatic
|
||||
});
|
||||
}
|
||||
self.sync_reveal();
|
||||
}
|
||||
|
||||
pub(crate) fn is_available(&self) -> bool {
|
||||
self.state.borrow().available
|
||||
}
|
||||
|
||||
/// Mark month `index`, unless the user has the scrubber in hand: the
|
||||
/// timeline's scroll position must not move the knob out from under them.
|
||||
pub(crate) fn set_value(&self, index: usize) {
|
||||
{
|
||||
let mut state = self.state.borrow_mut();
|
||||
if state.drag_from.is_some() || state.hover.is_some() || state.value == index {
|
||||
return;
|
||||
}
|
||||
state.value = index;
|
||||
}
|
||||
self.place_knob();
|
||||
}
|
||||
|
||||
/// How a month index reads in the bubble, such as "March 2024".
|
||||
pub(crate) fn set_label_func(&self, label: impl Fn(usize) -> String + 'static) {
|
||||
self.state.borrow_mut().label = Some(Rc::new(label));
|
||||
}
|
||||
|
||||
/// Call `f` with the month the user points the scrubber at.
|
||||
pub(crate) fn connect_pick(&self, f: impl Fn(usize) + 'static) {
|
||||
self.state.borrow_mut().on_pick = Some(Rc::new(f));
|
||||
}
|
||||
|
||||
/// Show the scrubber for a moment, as the timeline scrolls, so it can be
|
||||
/// found without hunting for the edge — and on a touch screen at all.
|
||||
pub(crate) fn flash(&self) {
|
||||
if !self.is_available() {
|
||||
return;
|
||||
}
|
||||
if let Some(source) = self.state.borrow_mut().linger.take() {
|
||||
source.remove();
|
||||
}
|
||||
let this = self.clone();
|
||||
let source = glib::timeout_add_local_once(LINGER, move || {
|
||||
this.state.borrow_mut().linger = None;
|
||||
this.sync_reveal();
|
||||
});
|
||||
self.state.borrow_mut().linger = Some(source);
|
||||
self.sync_reveal();
|
||||
}
|
||||
|
||||
fn sync_reveal(&self) {
|
||||
let show = {
|
||||
let state = self.state.borrow();
|
||||
state.available && (state.near || state.drag_from.is_some() || state.linger.is_some())
|
||||
};
|
||||
self.revealer.set_reveal_child(show);
|
||||
// A hidden revealer still claims its strip of the grid; let the clicks
|
||||
// through to the tiles underneath.
|
||||
self.revealer.set_can_target(show);
|
||||
if !show {
|
||||
self.state.borrow_mut().hover = None;
|
||||
self.bubble.set_visible(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// The bubble names the month being dragged to, or else the one under
|
||||
/// the pointer.
|
||||
fn sync_bubble(&self) {
|
||||
let (index, label) = {
|
||||
let state = self.state.borrow();
|
||||
let index = if state.drag_from.is_some() {
|
||||
Some(state.value)
|
||||
} else {
|
||||
state.hover
|
||||
};
|
||||
(index, state.label.clone())
|
||||
};
|
||||
let (Some(index), Some(label)) = (index, label) else {
|
||||
self.bubble.set_visible(false);
|
||||
return;
|
||||
};
|
||||
self.bubble.set_label(&label(index));
|
||||
let (_, height, _, _) = self.bubble.measure(gtk4::Orientation::Vertical, -1);
|
||||
let y = self.y_of(index) - f64::from(height) / 2.0;
|
||||
self.bubble.set_margin_top(y.max(0.0) as i32);
|
||||
self.bubble.set_visible(true);
|
||||
}
|
||||
|
||||
/// Move the knob to month `y` points at, and tell the page when that is a
|
||||
/// different month — or when a new drag starts, so a click jumps even to
|
||||
/// the month already marked.
|
||||
fn pick(&self, y: f64, starting: bool) {
|
||||
let Some(index) = self.index_at(y) else {
|
||||
return;
|
||||
};
|
||||
let (changed, on_pick) = {
|
||||
let mut state = self.state.borrow_mut();
|
||||
let changed = state.value != index;
|
||||
state.value = index;
|
||||
(changed, state.on_pick.clone())
|
||||
};
|
||||
self.place_knob();
|
||||
self.sync_bubble();
|
||||
if let (true, Some(on_pick)) = (changed || starting, on_pick) {
|
||||
on_pick(index);
|
||||
}
|
||||
}
|
||||
|
||||
fn place_knob(&self) {
|
||||
let value = self.state.borrow().value;
|
||||
let y = self.y_of(value) - 2.0;
|
||||
self.knob.set_margin_top(y.max(0.0) as i32);
|
||||
}
|
||||
|
||||
/// Where month `index` sits on the strip.
|
||||
fn y_of(&self, index: usize) -> f64 {
|
||||
let months = self.state.borrow().months;
|
||||
month_y(index, months, f64::from(self.area.height()))
|
||||
}
|
||||
|
||||
/// The month at height `y` on the strip, if it spans any.
|
||||
fn index_at(&self, y: f64) -> Option<usize> {
|
||||
let months = self.state.borrow().months;
|
||||
month_at(y, months, f64::from(self.area.height()))
|
||||
}
|
||||
|
||||
fn draw(&self, area: >k4::DrawingArea, cr: &cairo::Context, width: i32, height: i32) {
|
||||
let state = self.state.borrow();
|
||||
let color = area.color();
|
||||
let (width, height) = (f64::from(width), f64::from(height));
|
||||
let attrs = gtk4::pango::AttrList::new();
|
||||
attrs.insert(gtk4::pango::AttrFloat::new_scale(0.8));
|
||||
attrs.insert(gtk4::pango::AttrInt::new_weight(gtk4::pango::Weight::Bold));
|
||||
// The bottom of the last label drawn: a year crowded against the one
|
||||
// above it keeps its tick but not its label.
|
||||
let mut clear_below = f64::NEG_INFINITY;
|
||||
for (index, year) in &state.years {
|
||||
let y = month_y(*index, state.months, height);
|
||||
cr.set_source_rgba(
|
||||
f64::from(color.red()),
|
||||
f64::from(color.green()),
|
||||
f64::from(color.blue()),
|
||||
0.5,
|
||||
);
|
||||
cr.rectangle(width - 12.0, y.round() - 0.5, 8.0, 1.0);
|
||||
let _ = cr.fill();
|
||||
|
||||
let layout = area.create_pango_layout(Some(year));
|
||||
layout.set_attributes(Some(&attrs));
|
||||
let (_, logical) = layout.pixel_extents();
|
||||
let (w, h) = (f64::from(logical.width()), f64::from(logical.height()));
|
||||
let top = y - h / 2.0;
|
||||
if top < clear_below {
|
||||
continue;
|
||||
}
|
||||
clear_below = top + h + 2.0;
|
||||
cr.set_source_rgba(
|
||||
f64::from(color.red()),
|
||||
f64::from(color.green()),
|
||||
f64::from(color.blue()),
|
||||
0.8,
|
||||
);
|
||||
cr.move_to(width - 16.0 - w, top);
|
||||
pangocairo::functions::show_layout(cr, &layout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where month `index` of `months` sits on a strip `height` px tall: evenly
|
||||
/// spaced, newest at the top.
|
||||
fn month_y(index: usize, months: usize, height: f64) -> f64 {
|
||||
let span = (height - 2.0 * PAD).max(0.0);
|
||||
if months < 2 {
|
||||
return PAD;
|
||||
}
|
||||
PAD + span * index as f64 / (months - 1) as f64
|
||||
}
|
||||
|
||||
/// The month nearest height `y` on a strip `height` px tall.
|
||||
fn month_at(y: f64, months: usize, height: f64) -> Option<usize> {
|
||||
let last = months.checked_sub(1)?;
|
||||
let span = height - 2.0 * PAD;
|
||||
if span <= 0.0 {
|
||||
return Some(0);
|
||||
}
|
||||
let fraction = ((y - PAD) / span).clamp(0.0, 1.0);
|
||||
Some((fraction * last as f64).round() as usize)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_scrubber_maps_heights_back_to_their_months() {
|
||||
let height = 432.0;
|
||||
for index in 0..12 {
|
||||
assert_eq!(
|
||||
month_at(month_y(index, 12, height), 12, height),
|
||||
Some(index)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scrubber_clamps_past_either_end() {
|
||||
assert_eq!(month_at(-50.0, 12, 432.0), Some(0));
|
||||
assert_eq!(month_at(900.0, 12, 432.0), Some(11));
|
||||
assert_eq!(month_at(10.0, 0, 432.0), None);
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ impl Busy {
|
||||
pub(crate) fn start(button: >k4::Button) -> Self {
|
||||
let label = button.label();
|
||||
let icon = button.icon_name();
|
||||
let spinner = gtk4::Spinner::builder().spinning(true).build();
|
||||
let spinner = spinner();
|
||||
match &label {
|
||||
Some(text) => {
|
||||
let content = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
|
||||
@@ -407,6 +407,14 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
);
|
||||
});
|
||||
|
||||
// Shared by me lists what this dialog changes: reload it when it is the
|
||||
// page underneath. Any other page reads it again on the next visit.
|
||||
let ui_closed = ui.clone();
|
||||
dialog.connect_closed(move |_| {
|
||||
if ui_closed.stack.visible_child_name().as_deref() == Some("sharedbyme") {
|
||||
load_shared_by_me(&ui_closed);
|
||||
}
|
||||
});
|
||||
share_dialog_reload(&state);
|
||||
dialog.present(ui_window(ui).as_ref());
|
||||
}
|
||||
@@ -427,7 +435,7 @@ pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
|
||||
_ => toast_error(
|
||||
&state.ui,
|
||||
&gettext("Couldn't load sharing"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -582,6 +590,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
let url_copy = url.clone();
|
||||
copy.connect_clicked(move |btn| {
|
||||
btn.clipboard().set_text(&url_copy);
|
||||
flash_copied(btn, "edit-copy-symbolic");
|
||||
toast(&state_copy.ui, &gettext("Link copied"));
|
||||
});
|
||||
row.add_suffix(©);
|
||||
@@ -718,7 +727,7 @@ pub(crate) fn share_dialog_create_link(
|
||||
_ => toast_error(
|
||||
&state.ui,
|
||||
&gettext("Couldn't create the link"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -756,7 +765,7 @@ pub(crate) fn share_dialog_op(
|
||||
toast_error(
|
||||
&state.ui,
|
||||
&failed,
|
||||
&gettext("The mount service didn't respond."),
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
);
|
||||
share_dialog_reload(&state);
|
||||
}
|
||||
|
||||
@@ -214,19 +214,6 @@ pub(crate) fn bind_file_thumbnail(
|
||||
schedule_file_thumbs(ui);
|
||||
}
|
||||
|
||||
/// Create and immediately bind a thumbnail for a non-recycled row.
|
||||
pub(crate) fn file_thumbnail(
|
||||
ui: &Rc<Ui>,
|
||||
entry: &DirEntry,
|
||||
size: i32,
|
||||
fallback_size: i32,
|
||||
symbolic_fallback: bool,
|
||||
) -> gtk4::Overlay {
|
||||
let widget = file_thumbnail_widget(size, fallback_size);
|
||||
bind_file_thumbnail(ui, &widget, entry, symbolic_fallback);
|
||||
widget
|
||||
}
|
||||
|
||||
/// Whether the file name denotes an image format for which Proton or the local
|
||||
/// decoder can reasonably provide a thumbnail.
|
||||
pub(crate) fn is_image_name(name: &str) -> bool {
|
||||
|
||||
@@ -167,7 +167,7 @@ pub(crate) fn versions_dialog_reload(state: &Rc<VersionsDialog>) {
|
||||
_ => toast_error(
|
||||
&state.ui,
|
||||
&gettext("Couldn't load versions"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -204,13 +204,13 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
|
||||
gettext_f(
|
||||
"{time} · {size}",
|
||||
&[
|
||||
("time", &activity_time(item.created)),
|
||||
("time", &dates::relative(item.created)),
|
||||
("size", &human_bytes(size)),
|
||||
],
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(activity_time(item.created), human_bytes(size))
|
||||
(dates::relative(item.created), human_bytes(size))
|
||||
};
|
||||
if let Some(email) = &item.signed_by {
|
||||
subtitle.push_str(" · ");
|
||||
@@ -266,7 +266,7 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
|
||||
/// every device on the account, not only this one.
|
||||
fn prompt_restore_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Restore version"))
|
||||
.heading(gettext("Restore Version?"))
|
||||
// Translators: {name} is a file name.
|
||||
.body(gettext_f(
|
||||
"Make this version the current content of “{name}”? The version it replaces stays in the history.",
|
||||
@@ -301,7 +301,7 @@ fn prompt_restore_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
||||
/// Confirm, then permanently delete one revision.
|
||||
fn prompt_delete_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Delete version"))
|
||||
.heading(gettext("Delete Version?"))
|
||||
.body(gettext(
|
||||
"Delete this version permanently? Its content can't be recovered.",
|
||||
))
|
||||
@@ -377,7 +377,7 @@ fn versions_dialog_op(state: &Rc<VersionsDialog>, request: Request, done: &str,
|
||||
_ => toast_error(
|
||||
&state.ui,
|
||||
&failed,
|
||||
&gettext("The mount service didn't respond."),
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Widgets that newer libadwaita releases bring, with a stand-in built from
|
||||
//! what the oldest supported release (1.5, as on Ubuntu 24.04) has.
|
||||
//!
|
||||
//! The app builds against 1.5 and picks at run time: when the libadwaita it
|
||||
//! runs with is 1.8 or newer, the real widgets are made by class name through
|
||||
//! a `gtk4::Builder`, since the 1.5 bindings have no types for them. Their
|
||||
//! properties and signals are then reached by name. Call sites never care
|
||||
//! which one they got.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use gtk4::prelude::*;
|
||||
|
||||
/// Set to anything to use the stand-ins even on a new libadwaita, to see the
|
||||
/// app as it looks on the oldest supported release.
|
||||
const STAND_INS_ENV: &str = "PDFS_STAND_IN_WIDGETS";
|
||||
|
||||
/// Whether the libadwaita the app runs with has the 1.8 widgets.
|
||||
pub(crate) fn has_adw_1_8() -> bool {
|
||||
static HAS: OnceLock<bool> = OnceLock::new();
|
||||
*HAS.get_or_init(|| {
|
||||
std::env::var_os(STAND_INS_ENV).is_none()
|
||||
&& (adw::major_version(), adw::minor_version()) >= (1, 8)
|
||||
})
|
||||
}
|
||||
|
||||
/// Build `xml`, a GtkBuilder interface naming classes the bindings lack. It
|
||||
/// is only ever one this module wrote, so a failure is a bug here.
|
||||
pub(crate) fn build_ui(xml: &str) -> gtk4::Builder {
|
||||
gtk4::Builder::from_string(&format!("<interface>{xml}</interface>"))
|
||||
}
|
||||
|
||||
/// The object `id` from a builder [`build_ui`] made.
|
||||
pub(crate) fn built<T: IsA<gtk4::glib::Object>>(builder: >k4::Builder, id: &str) -> T {
|
||||
builder
|
||||
.object(id)
|
||||
.unwrap_or_else(|| panic!("the compat UI has no object {id}"))
|
||||
}
|
||||
|
||||
/// A busy spinner. It spins whenever it is shown: show and hide it with
|
||||
/// `set_visible` rather than starting and stopping it.
|
||||
pub(crate) type Spinner = gtk4::Widget;
|
||||
|
||||
/// A new [`Spinner`], shown: `AdwSpinner` on libadwaita 1.8, a spinning
|
||||
/// `gtk4::Spinner` before.
|
||||
pub(crate) fn spinner() -> Spinner {
|
||||
if has_adw_1_8() {
|
||||
let builder = build_ui(r#"<object class="AdwSpinner" id="spinner"/>"#);
|
||||
built(&builder, "spinner")
|
||||
} else {
|
||||
// Spinning costs nothing while hidden: an unmapped widget draws no
|
||||
// frames.
|
||||
gtk4::Spinner::builder().spinning(true).build().upcast()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Match highlighting: which characters of a result name the query matched,
|
||||
//! as Pango markup with those characters in bold.
|
||||
//!
|
||||
//! Each query term is looked for as a substring of the name first, and as a
|
||||
//! subsequence ("rpt" in "report") when that fails, which is roughly how the
|
||||
//! daemon's ranking treats a term too. Case is folded one character at a
|
||||
//! time, so every range stays on a character boundary of the original name,
|
||||
//! whatever the script.
|
||||
|
||||
use gtk4::glib;
|
||||
|
||||
/// One character of a name: its byte range and its case-folded form.
|
||||
struct Folded {
|
||||
start: usize,
|
||||
end: usize,
|
||||
folded: String,
|
||||
}
|
||||
|
||||
fn fold(text: &str) -> Vec<Folded> {
|
||||
text.char_indices()
|
||||
.map(|(start, c)| Folded {
|
||||
start,
|
||||
end: start + c.len_utf8(),
|
||||
folded: c.to_lowercase().collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The byte ranges of `name` that `query` matched, sorted and merged.
|
||||
pub(crate) fn ranges(name: &str, query: &str) -> Vec<(usize, usize)> {
|
||||
let chars = fold(name);
|
||||
let mut found: Vec<(usize, usize)> = Vec::new();
|
||||
for term in query.split_whitespace() {
|
||||
let term: Vec<String> = fold(term).into_iter().map(|c| c.folded).collect();
|
||||
if let Some(at) = substring(&chars, &term) {
|
||||
found.push((chars[at].start, chars[at + term.len() - 1].end));
|
||||
} else if let Some(hits) = subsequence(&chars, &term) {
|
||||
found.extend(hits.into_iter().map(|at| (chars[at].start, chars[at].end)));
|
||||
}
|
||||
}
|
||||
found.sort_unstable();
|
||||
let mut merged: Vec<(usize, usize)> = Vec::with_capacity(found.len());
|
||||
for (start, end) in found {
|
||||
match merged.last_mut() {
|
||||
Some(last) if start <= last.1 => last.1 = last.1.max(end),
|
||||
_ => merged.push((start, end)),
|
||||
}
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
/// Where `term` first occurs in `chars`, as a character index.
|
||||
fn substring(chars: &[Folded], term: &[String]) -> Option<usize> {
|
||||
if term.is_empty() || term.len() > chars.len() {
|
||||
return None;
|
||||
}
|
||||
(0..=chars.len() - term.len()).find(|&at| {
|
||||
chars[at..at + term.len()]
|
||||
.iter()
|
||||
.zip(term)
|
||||
.all(|(c, t)| c.folded == *t)
|
||||
})
|
||||
}
|
||||
|
||||
/// The character indices of the leftmost match of `term` as a subsequence,
|
||||
/// or `None` when not every character of it can be found in order.
|
||||
fn subsequence(chars: &[Folded], term: &[String]) -> Option<Vec<usize>> {
|
||||
if term.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut hits = Vec::with_capacity(term.len());
|
||||
let mut next = 0;
|
||||
for wanted in term {
|
||||
let at = (next..chars.len()).find(|&i| chars[i].folded == *wanted)?;
|
||||
hits.push(at);
|
||||
next = at + 1;
|
||||
}
|
||||
Some(hits)
|
||||
}
|
||||
|
||||
/// `name` as Pango markup, escaped, with the matched characters in bold.
|
||||
pub(crate) fn markup(name: &str, query: &str) -> String {
|
||||
let mut out = String::with_capacity(name.len() + 16);
|
||||
let mut done = 0;
|
||||
for (start, end) in ranges(name, query) {
|
||||
out.push_str(&glib::markup_escape_text(&name[done..start]));
|
||||
out.push_str("<b>");
|
||||
out.push_str(&glib::markup_escape_text(&name[start..end]));
|
||||
out.push_str("</b>");
|
||||
done = end;
|
||||
}
|
||||
out.push_str(&glib::markup_escape_text(&name[done..]));
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_substring_match_is_one_range_in_any_case() {
|
||||
assert_eq!(ranges("Tax Report.pdf", "REP"), vec![(4, 7)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_term_that_is_no_substring_falls_back_to_a_subsequence() {
|
||||
// r, p and t of "report", not the "rt" pair at the end of a substring.
|
||||
assert_eq!(ranges("report.txt", "rpt"), vec![(0, 1), (2, 3), (5, 6)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_term_with_no_match_highlights_nothing() {
|
||||
assert!(ranges("report.txt", "xyz").is_empty());
|
||||
assert!(ranges("report.txt", "").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_terms_merge_into_sorted_ranges() {
|
||||
assert_eq!(
|
||||
ranges("tax return 2024", "2024 tax ret"),
|
||||
vec![(0, 3), (4, 7), (11, 15)]
|
||||
);
|
||||
assert_eq!(ranges("abc", "ab bc"), vec![(0, 3)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_ascii_names_keep_ranges_on_character_boundaries() {
|
||||
assert_eq!(ranges("Über Größe.odt", "über"), vec![(0, 5)]);
|
||||
assert_eq!(ranges("Über Größe.odt", "GRÖ"), vec![(6, 10)]);
|
||||
assert_eq!(markup("Über Größe.odt", "größe"), "Über <b>Größe</b>.odt");
|
||||
assert_eq!(
|
||||
markup("日本語のメモ.txt", "メモ"),
|
||||
"日本語の<b>メモ</b>.txt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markup_escapes_the_name_inside_and_outside_matches() {
|
||||
assert_eq!(markup("a<b>&c", "b"), "a<<b>b</b>>&c");
|
||||
assert_eq!(markup("R&D.pdf", "r&d"), "<b>R&D</b>.pdf");
|
||||
assert_eq!(markup("<none>", "zzz"), "<none>");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
//! Recent files and frecency.
|
||||
//!
|
||||
//! The launcher remembers what it opened in `prompt-history.json` under the
|
||||
//! state directory. With an empty query those files come first, and while
|
||||
//! searching a file opened often and lately gets a bonus on top of its match
|
||||
//! score (see [`frecency_bonus`]).
|
||||
//!
|
||||
//! One writer thread owns the file: it loads it, applies every recorded visit
|
||||
//! in order and writes the result back atomically. A visit recorded before the
|
||||
//! first load finished simply waits in the channel, so it is merged into the
|
||||
//! loaded list rather than overwriting it. The main thread only ever sees
|
||||
//! snapshots.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
|
||||
use gtk4::glib;
|
||||
|
||||
use crate::Hit;
|
||||
|
||||
/// How many files the history keeps. More than the launcher ever shows, so
|
||||
/// frecency still knows a file that dropped off the recent list.
|
||||
const MAX_VISITS: usize = 50;
|
||||
|
||||
/// The frecency bonus of one visit made just now.
|
||||
const VISIT_BONUS: f64 = 1500.0;
|
||||
|
||||
/// The bonus halves every this many days without a visit.
|
||||
const HALF_LIFE_DAYS: f64 = 14.0;
|
||||
|
||||
/// The largest frecency bonus. A name match is worth up to 20000 points, so
|
||||
/// history breaks ties between similar matches but cannot beat a much better
|
||||
/// one.
|
||||
const MAX_BONUS: f64 = 6000.0;
|
||||
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub(crate) struct Visit {
|
||||
pub(crate) hit: Hit,
|
||||
/// When the file was last opened, in epoch seconds.
|
||||
pub(crate) last: i64,
|
||||
pub(crate) count: u32,
|
||||
}
|
||||
|
||||
/// The history bonus for a file opened `count` times, the last time
|
||||
/// `age_secs` ago.
|
||||
pub(crate) fn frecency_bonus(count: u32, age_secs: i64) -> i64 {
|
||||
let age_days = age_secs.max(0) as f64 / 86_400.0;
|
||||
let bonus = f64::from(count) * VISIT_BONUS * 0.5f64.powf(age_days / HALF_LIFE_DAYS);
|
||||
bonus.min(MAX_BONUS) as i64
|
||||
}
|
||||
|
||||
/// Count one more visit of `hit` at `now`, newest first, and keep at most
|
||||
/// [`MAX_VISITS`]. The stored hit is replaced, so its size, date and sync
|
||||
/// state are the ones from the latest search.
|
||||
fn record(visits: &mut Vec<Visit>, hit: Hit, now: i64) {
|
||||
let key = hit.key();
|
||||
let count = match visits.iter().position(|visit| visit.hit.key() == key) {
|
||||
Some(at) => visits.remove(at).count.saturating_add(1),
|
||||
None => 1,
|
||||
};
|
||||
visits.insert(
|
||||
0,
|
||||
Visit {
|
||||
hit,
|
||||
last: now,
|
||||
count,
|
||||
},
|
||||
);
|
||||
visits.truncate(MAX_VISITS);
|
||||
}
|
||||
|
||||
/// Drop local files that no longer exist. Drive files stay: checking them
|
||||
/// would touch the mount, and a stale one only costs a row.
|
||||
fn prune(visits: &mut Vec<Visit>) -> bool {
|
||||
let before = visits.len();
|
||||
visits.retain(|visit| match &visit.hit {
|
||||
Hit::Local(hit) => Path::new(&hit.path).symlink_metadata().is_ok(),
|
||||
Hit::Drive(_) => true,
|
||||
});
|
||||
visits.len() != before
|
||||
}
|
||||
|
||||
fn load(path: &Path) -> Vec<Visit> {
|
||||
match std::fs::read(path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
tracing::warn!("ignoring unreadable {}: {e}", path.display());
|
||||
Vec::new()
|
||||
}),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the history through a temporary file and a rename, so a crash
|
||||
/// mid-write leaves the old file rather than half a new one.
|
||||
fn save(path: &Path, visits: &[Visit]) -> std::io::Result<()> {
|
||||
let json = serde_json::to_vec(visits).map_err(std::io::Error::other)?;
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let mut file = std::fs::File::create(&tmp)?;
|
||||
file.write_all(&json)?;
|
||||
file.sync_all()?;
|
||||
std::fs::rename(&tmp, path)
|
||||
}
|
||||
|
||||
enum Command {
|
||||
Record(Hit, i64),
|
||||
Prune,
|
||||
}
|
||||
|
||||
/// The writer thread's loop: load, then apply commands until the launcher
|
||||
/// exits. `publish` receives the list after the load and after every change.
|
||||
fn serve(path: &Path, commands: &async_channel::Receiver<Command>, publish: impl Fn(Vec<Visit>)) {
|
||||
let mut visits = load(path);
|
||||
let pruned = prune(&mut visits);
|
||||
publish(visits.clone());
|
||||
if pruned && let Err(e) = save(path, &visits) {
|
||||
tracing::warn!("cannot write {}: {e}", path.display());
|
||||
}
|
||||
while let Ok(command) = commands.recv_blocking() {
|
||||
let changed = match command {
|
||||
Command::Record(hit, now) => {
|
||||
record(&mut visits, hit, now);
|
||||
true
|
||||
}
|
||||
Command::Prune => prune(&mut visits),
|
||||
};
|
||||
if changed {
|
||||
if let Err(e) = save(path, &visits) {
|
||||
tracing::warn!("cannot write {}: {e}", path.display());
|
||||
}
|
||||
publish(visits.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The main thread's view of the history.
|
||||
pub(crate) struct History {
|
||||
visits: Rc<RefCell<Vec<Visit>>>,
|
||||
commands: async_channel::Sender<Command>,
|
||||
}
|
||||
|
||||
impl History {
|
||||
/// Start the writer thread. `changed` runs on the main thread whenever a
|
||||
/// new snapshot arrives.
|
||||
pub(crate) fn start(path: PathBuf, changed: impl Fn() + 'static) -> Self {
|
||||
let (commands, inbox) = async_channel::unbounded();
|
||||
let (snapshots, updates) = async_channel::unbounded::<Vec<Visit>>();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("prompt-history".into())
|
||||
.spawn(move || {
|
||||
serve(&path, &inbox, |visits| {
|
||||
let _ = snapshots.send_blocking(visits);
|
||||
})
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
tracing::warn!("cannot start the history writer: {e}");
|
||||
}
|
||||
|
||||
let visits = Rc::new(RefCell::new(Vec::new()));
|
||||
let shared = visits.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
while let Ok(snapshot) = updates.recv().await {
|
||||
*shared.borrow_mut() = snapshot;
|
||||
changed();
|
||||
}
|
||||
});
|
||||
Self { visits, commands }
|
||||
}
|
||||
|
||||
/// Remember that `hit` was opened just now.
|
||||
pub(crate) fn record(&self, hit: Hit) {
|
||||
let _ = self.commands.try_send(Command::Record(hit, now()));
|
||||
}
|
||||
|
||||
/// Forget local files that were deleted since they were opened.
|
||||
pub(crate) fn prune(&self) {
|
||||
let _ = self.commands.try_send(Command::Prune);
|
||||
}
|
||||
|
||||
/// The most recently opened files, newest first.
|
||||
pub(crate) fn recent(&self, limit: usize) -> Vec<Hit> {
|
||||
self.visits
|
||||
.borrow()
|
||||
.iter()
|
||||
.take(limit)
|
||||
.map(|visit| visit.hit.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The frecency bonus of every remembered file, by [`Hit::key`].
|
||||
pub(crate) fn bonuses(&self) -> HashMap<(bool, String), i64> {
|
||||
let now = now();
|
||||
self.visits
|
||||
.borrow()
|
||||
.iter()
|
||||
.map(|visit| {
|
||||
(
|
||||
visit.hit.key(),
|
||||
frecency_bonus(visit.count, now - visit.last),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs() as i64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pdfs_core::control::{LocalHit, SearchHit};
|
||||
|
||||
const DAY: i64 = 86_400;
|
||||
|
||||
fn drive(name: &str) -> Hit {
|
||||
Hit::Drive(SearchHit {
|
||||
name: name.into(),
|
||||
path: format!("Docs/{name}"),
|
||||
is_dir: false,
|
||||
size: 1,
|
||||
modified: 1,
|
||||
pinned: false,
|
||||
cached: false,
|
||||
uid: format!("v~{name}"),
|
||||
mounted_path: None,
|
||||
score: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn scratch_dir(test: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pdfs-prompt-history-{test}-{}-{}",
|
||||
std::process::id(),
|
||||
now()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bonus_halves_every_two_weeks() {
|
||||
assert_eq!(frecency_bonus(1, 0), 1500);
|
||||
assert_eq!(frecency_bonus(1, 14 * DAY), 750);
|
||||
assert_eq!(frecency_bonus(1, 28 * DAY), 375);
|
||||
assert_eq!(frecency_bonus(2, 14 * DAY), 1500);
|
||||
// A clock that went backwards counts as "just now", not as a boost.
|
||||
assert_eq!(frecency_bonus(1, -DAY), 1500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bonus_is_capped() {
|
||||
assert_eq!(frecency_bonus(4, 0), 6000);
|
||||
assert_eq!(frecency_bonus(1000, 0), 6000);
|
||||
assert!(frecency_bonus(1000, 365 * DAY) < 6000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeat_visit_moves_the_file_to_the_top_and_counts_it() {
|
||||
let mut visits = Vec::new();
|
||||
record(&mut visits, drive("a.pdf"), 10);
|
||||
record(&mut visits, drive("b.pdf"), 20);
|
||||
record(&mut visits, drive("a.pdf"), 30);
|
||||
assert_eq!(visits.len(), 2);
|
||||
assert_eq!(visits[0].hit.name(), "a.pdf");
|
||||
assert_eq!((visits[0].count, visits[0].last), (2, 30));
|
||||
assert_eq!(visits[1].count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_history_keeps_the_newest_fifty_files() {
|
||||
let mut visits = Vec::new();
|
||||
for n in 0..60 {
|
||||
record(&mut visits, drive(&format!("{n}.pdf")), n);
|
||||
}
|
||||
assert_eq!(visits.len(), MAX_VISITS);
|
||||
assert_eq!(visits[0].hit.name(), "59.pdf");
|
||||
assert_eq!(visits[MAX_VISITS - 1].hit.name(), "10.pdf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_history_file_round_trips() {
|
||||
let dir = scratch_dir("round-trip");
|
||||
let path = dir.join("prompt-history.json");
|
||||
let mut visits = Vec::new();
|
||||
record(&mut visits, drive("a.pdf"), 10);
|
||||
record(&mut visits, drive("a.pdf"), 20);
|
||||
save(&path, &visits).unwrap();
|
||||
|
||||
let loaded = load(&path);
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].hit.key(), drive("a.pdf").key());
|
||||
assert_eq!((loaded[0].count, loaded[0].last), (2, 20));
|
||||
assert!(!path.with_extension("json.tmp").exists());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visits_recorded_before_the_load_are_merged_into_it() {
|
||||
let dir = scratch_dir("merge");
|
||||
let path = dir.join("prompt-history.json");
|
||||
let mut stored = Vec::new();
|
||||
record(&mut stored, drive("old.pdf"), 10);
|
||||
record(&mut stored, drive("old.pdf"), 20);
|
||||
save(&path, &stored).unwrap();
|
||||
|
||||
// Both visits are queued before the writer has read the file.
|
||||
let (commands, inbox) = async_channel::unbounded();
|
||||
commands
|
||||
.send_blocking(Command::Record(drive("old.pdf"), 30))
|
||||
.unwrap();
|
||||
commands
|
||||
.send_blocking(Command::Record(drive("new.pdf"), 40))
|
||||
.unwrap();
|
||||
drop(commands);
|
||||
|
||||
let published = RefCell::new(Vec::new());
|
||||
serve(&path, &inbox, |visits| published.borrow_mut().push(visits));
|
||||
|
||||
let last = published.borrow().last().cloned().unwrap();
|
||||
assert_eq!(last.len(), 2);
|
||||
assert_eq!(last[0].hit.name(), "new.pdf");
|
||||
assert_eq!((last[1].hit.name(), last[1].count), ("old.pdf", 3));
|
||||
let on_disk = load(&path);
|
||||
assert_eq!(on_disk.len(), 2);
|
||||
assert_eq!(on_disk[1].count, 3);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pruning_drops_deleted_local_files_only() {
|
||||
let dir = scratch_dir("prune");
|
||||
let kept = dir.join("kept.txt");
|
||||
std::fs::write(&kept, "x").unwrap();
|
||||
let local = |path: &Path| {
|
||||
Hit::Local(LocalHit {
|
||||
name: path.file_name().unwrap().to_string_lossy().into_owned(),
|
||||
path: path.display().to_string(),
|
||||
is_dir: false,
|
||||
size: 1,
|
||||
modified: 1,
|
||||
score: 0,
|
||||
})
|
||||
};
|
||||
let mut visits = Vec::new();
|
||||
record(&mut visits, local(&kept), 1);
|
||||
record(&mut visits, local(&dir.join("gone.txt")), 2);
|
||||
record(&mut visits, drive("remote.pdf"), 3);
|
||||
|
||||
assert!(prune(&mut visits));
|
||||
let names: Vec<&str> = visits.iter().map(|visit| visit.hit.name()).collect();
|
||||
assert_eq!(names, ["remote.pdf", "kept.txt"]);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
//! The preview pane beside the results: a thumbnail of the selected hit, what
|
||||
//! it is, where it is and whether it is on this computer, and the actions for
|
||||
//! it with their shortcuts.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use adw::prelude::*;
|
||||
use gtk4::{gio, glib};
|
||||
|
||||
use super::row::{fill_slot, thumb_slot};
|
||||
use super::thumbs::Thumbs;
|
||||
use crate::{Hit, dates, gettext, human_bytes, pgettext};
|
||||
|
||||
/// Width of the pane.
|
||||
pub(crate) const PREVIEW_WIDTH: i32 = 300;
|
||||
|
||||
/// The thumbnail area, inside the pane's padding.
|
||||
const THUMB_WIDTH: i32 = 268;
|
||||
const THUMB_HEIGHT: i32 = 180;
|
||||
|
||||
/// How long "Path copied" stays up.
|
||||
const COPIED_FEEDBACK: Duration = Duration::from_millis(1500);
|
||||
|
||||
/// One label/value line of the details grid.
|
||||
struct Detail {
|
||||
title: gtk4::Label,
|
||||
value: gtk4::Label,
|
||||
}
|
||||
|
||||
impl Detail {
|
||||
fn new(grid: >k4::Grid, row: i32, title: &str) -> Self {
|
||||
let title = gtk4::Label::builder()
|
||||
.label(title)
|
||||
.xalign(0.0)
|
||||
.valign(gtk4::Align::Start)
|
||||
.build();
|
||||
title.add_css_class("preview-key");
|
||||
// A wrapping label asks for its whole text on one line as its natural
|
||||
// width, which would widen the pane and squeeze the results. Capping it
|
||||
// keeps the pane at its fixed width; long values wrap, then ellipsize.
|
||||
let value = gtk4::Label::builder()
|
||||
.xalign(0.0)
|
||||
.hexpand(true)
|
||||
.wrap(true)
|
||||
.wrap_mode(gtk4::pango::WrapMode::WordChar)
|
||||
.max_width_chars(1)
|
||||
.lines(3)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
||||
.build();
|
||||
value.add_css_class("preview-value");
|
||||
grid.attach(&title, 0, row, 1, 1);
|
||||
grid.attach(&value, 1, row, 1, 1);
|
||||
Self { title, value }
|
||||
}
|
||||
|
||||
/// Show `value`, or hide the line when there is nothing to say.
|
||||
fn set(&self, value: Option<&str>) {
|
||||
self.title.set_visible(value.is_some());
|
||||
self.value.set_visible(value.is_some());
|
||||
self.value.set_label(value.unwrap_or_default());
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Preview {
|
||||
pub(crate) root: gtk4::Box,
|
||||
stack: gtk4::Stack,
|
||||
slot: gtk4::Overlay,
|
||||
name: gtk4::Label,
|
||||
kind: gtk4::Label,
|
||||
size: Detail,
|
||||
modified: Detail,
|
||||
location: Detail,
|
||||
availability: Detail,
|
||||
availability_icon: gtk4::Image,
|
||||
pub(crate) open: gtk4::Button,
|
||||
open_label: gtk4::Label,
|
||||
pub(crate) reveal: gtk4::Button,
|
||||
pub(crate) copy: gtk4::Button,
|
||||
copied: gtk4::Revealer,
|
||||
copied_timer: Rc<RefCell<Option<glib::SourceId>>>,
|
||||
}
|
||||
|
||||
/// A flat action button: the label, and its shortcut at the far end.
|
||||
fn action(icon: &str, label: &str, accelerator: &str) -> (gtk4::Button, gtk4::Label) {
|
||||
let content = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
||||
content.append(>k4::Image::from_icon_name(icon));
|
||||
let text = gtk4::Label::builder()
|
||||
.label(label)
|
||||
.xalign(0.0)
|
||||
.hexpand(true)
|
||||
.max_width_chars(1)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
content.append(&text);
|
||||
let shortcut = gtk4::ShortcutLabel::new(accelerator);
|
||||
shortcut.add_css_class("preview-shortcut");
|
||||
content.append(&shortcut);
|
||||
let button = gtk4::Button::builder()
|
||||
.child(&content)
|
||||
.focus_on_click(false)
|
||||
.focusable(false)
|
||||
.build();
|
||||
button.add_css_class("flat");
|
||||
button.add_css_class("preview-action");
|
||||
(button, text)
|
||||
}
|
||||
|
||||
impl Preview {
|
||||
pub(crate) fn new() -> Self {
|
||||
let details = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
|
||||
let slot = thumb_slot(THUMB_WIDTH, THUMB_HEIGHT, 96, gtk4::ContentFit::Contain);
|
||||
slot.add_css_class("preview-thumb");
|
||||
details.append(&slot);
|
||||
|
||||
let name = gtk4::Label::builder()
|
||||
.xalign(0.0)
|
||||
.wrap(true)
|
||||
.wrap_mode(gtk4::pango::WrapMode::WordChar)
|
||||
.max_width_chars(1)
|
||||
.lines(3)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
name.add_css_class("preview-name");
|
||||
details.append(&name);
|
||||
|
||||
let kind = gtk4::Label::builder()
|
||||
.xalign(0.0)
|
||||
.max_width_chars(1)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
kind.add_css_class("preview-kind");
|
||||
details.append(&kind);
|
||||
|
||||
let grid = gtk4::Grid::builder()
|
||||
.row_spacing(6)
|
||||
.column_spacing(12)
|
||||
.build();
|
||||
grid.add_css_class("preview-details");
|
||||
let size = Detail::new(&grid, 0, &pgettext("column", "Size"));
|
||||
let modified = Detail::new(&grid, 1, &pgettext("column", "Modified"));
|
||||
let location = Detail::new(&grid, 2, &gettext("Location"));
|
||||
// Translators: a heading in the launcher's preview pane, followed by
|
||||
// "Available offline", "Downloaded", "Online only" or "On this computer".
|
||||
let availability = Detail::new(&grid, 3, &gettext("Availability"));
|
||||
// The availability value is an icon and a text.
|
||||
let availability_icon = gtk4::Image::new();
|
||||
availability_icon.add_css_class("preview-badge");
|
||||
grid.remove(&availability.value);
|
||||
let availability_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
|
||||
availability_box.append(&availability_icon);
|
||||
availability_box.append(&availability.value);
|
||||
grid.attach(&availability_box, 1, 3, 1, 1);
|
||||
details.append(&grid);
|
||||
|
||||
let spacer = gtk4::Box::builder().vexpand(true).build();
|
||||
details.append(&spacer);
|
||||
|
||||
let actions = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
||||
actions.add_css_class("preview-actions");
|
||||
let (open, open_label) = action("document-open-symbolic", "", "Return");
|
||||
let (reveal, _) = action(
|
||||
"folder-open-symbolic",
|
||||
&gettext("Show in Folder"),
|
||||
"<Control>Return",
|
||||
);
|
||||
let (copy, _) = action("edit-copy-symbolic", &gettext("Copy Path"), "<Control>c");
|
||||
actions.append(&open);
|
||||
actions.append(&reveal);
|
||||
actions.append(©);
|
||||
details.append(&actions);
|
||||
|
||||
let copied_label = gtk4::Label::new(Some(&gettext("Path copied")));
|
||||
copied_label.add_css_class("preview-feedback");
|
||||
let copied = gtk4::Revealer::builder()
|
||||
.transition_type(gtk4::RevealerTransitionType::Crossfade)
|
||||
.child(&copied_label)
|
||||
.build();
|
||||
details.append(&copied);
|
||||
|
||||
let empty = gtk4::Image::from_icon_name("system-search-symbolic");
|
||||
empty.set_pixel_size(64);
|
||||
empty.add_css_class("preview-empty");
|
||||
|
||||
let stack = gtk4::Stack::builder()
|
||||
.transition_type(gtk4::StackTransitionType::Crossfade)
|
||||
.transition_duration(100)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
stack.add_named(&empty, Some("empty"));
|
||||
stack.add_named(&details, Some("details"));
|
||||
|
||||
let root = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Vertical)
|
||||
.width_request(PREVIEW_WIDTH)
|
||||
.hexpand(false)
|
||||
.build();
|
||||
root.add_css_class("preview");
|
||||
root.append(&stack);
|
||||
|
||||
Self {
|
||||
root,
|
||||
stack,
|
||||
slot,
|
||||
name,
|
||||
kind,
|
||||
size,
|
||||
modified,
|
||||
location,
|
||||
availability,
|
||||
availability_icon,
|
||||
open,
|
||||
open_label,
|
||||
reveal,
|
||||
copy,
|
||||
copied,
|
||||
copied_timer: Rc::new(RefCell::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Show `hit`, or the empty pane when nothing is selected.
|
||||
pub(crate) fn show(&self, hit: Option<&Hit>, thumbs: &Rc<Thumbs>, mountpoint: &Path) {
|
||||
let Some(hit) = hit else {
|
||||
self.stack.set_visible_child_name("empty");
|
||||
return;
|
||||
};
|
||||
self.stack.set_visible_child_name("details");
|
||||
fill_slot(&self.slot, hit, thumbs, mountpoint);
|
||||
|
||||
self.name.set_label(hit.name());
|
||||
self.name.set_tooltip_text(Some(hit.name()));
|
||||
let content_type = if hit.is_dir() {
|
||||
glib::GString::from("inode/directory")
|
||||
} else {
|
||||
gio::functions::content_type_guess(Some(hit.name()), None).0
|
||||
};
|
||||
self.kind
|
||||
.set_label(&gio::functions::content_type_get_description(&content_type));
|
||||
|
||||
let size = (!hit.is_dir() && hit.size() > 0).then(|| human_bytes(hit.size()));
|
||||
self.size.set(size.as_deref());
|
||||
|
||||
let modified = (hit.modified() > 0).then(|| dates::relative(hit.modified()));
|
||||
self.modified.set(modified.as_deref());
|
||||
self.modified.value.set_tooltip_text(
|
||||
(hit.modified() > 0)
|
||||
.then(|| dates::full(hit.modified()))
|
||||
.as_deref(),
|
||||
);
|
||||
|
||||
let location = hit.location();
|
||||
self.location.set(Some(&location));
|
||||
self.location.value.set_tooltip_text(Some(&location));
|
||||
|
||||
match availability(hit) {
|
||||
Some((icon, class, text)) => {
|
||||
self.availability.set(Some(&text));
|
||||
self.availability_icon.set_icon_name(Some(icon));
|
||||
for other in ["badge-pinned", "badge-cached", "badge-cloud", "badge-local"] {
|
||||
self.availability_icon.remove_css_class(other);
|
||||
}
|
||||
self.availability_icon.add_css_class(class);
|
||||
self.availability_icon.set_visible(true);
|
||||
}
|
||||
None => {
|
||||
self.availability.set(None);
|
||||
self.availability_icon.set_visible(false);
|
||||
}
|
||||
}
|
||||
|
||||
self.open_label.set_label(&if hit.is_dir() {
|
||||
gettext("Open folder")
|
||||
} else {
|
||||
pgettext("verb", "Open")
|
||||
});
|
||||
}
|
||||
|
||||
/// Say "Path copied" for a moment.
|
||||
pub(crate) fn flash_copied(&self) {
|
||||
self.copied.set_reveal_child(true);
|
||||
if let Some(source) = self.copied_timer.borrow_mut().take() {
|
||||
source.remove();
|
||||
}
|
||||
let copied = self.copied.clone();
|
||||
let slot = self.copied_timer.clone();
|
||||
let timer = glib::timeout_add_local_once(COPIED_FEEDBACK, move || {
|
||||
slot.borrow_mut().take();
|
||||
copied.set_reveal_child(false);
|
||||
});
|
||||
*self.copied_timer.borrow_mut() = Some(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the hit's content is: its icon, its style class and its wording.
|
||||
fn availability(hit: &Hit) -> Option<(&'static str, &'static str, String)> {
|
||||
match hit {
|
||||
Hit::Local(_) => Some((
|
||||
"drive-harddisk-symbolic",
|
||||
"badge-local",
|
||||
gettext("On this computer"),
|
||||
)),
|
||||
Hit::Drive(drive) if drive.pinned => Some((
|
||||
"pdfs-offline-symbolic",
|
||||
"badge-pinned",
|
||||
gettext("Available offline"),
|
||||
)),
|
||||
// A folder is never downloaded as a whole; only pinning says anything.
|
||||
Hit::Drive(drive) if drive.is_dir => None,
|
||||
Hit::Drive(drive) if drive.cached => Some((
|
||||
"pdfs-cached-symbolic",
|
||||
"badge-cached",
|
||||
pgettext("file state", "Downloaded"),
|
||||
)),
|
||||
Hit::Drive(_) => Some((
|
||||
"pdfs-online-only-symbolic",
|
||||
"badge-cloud",
|
||||
pgettext("file state", "Online only"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Result rows: a thumbnail or icon, the name with its matched characters in
|
||||
//! bold, the location, and on the right the sync state, size and age.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
|
||||
use adw::prelude::*;
|
||||
use gtk4::{gdk, gio};
|
||||
|
||||
use super::highlight;
|
||||
use super::thumbs::Thumbs;
|
||||
use crate::theme::proton_theme_active;
|
||||
use crate::{Hit, format_age, human_bytes};
|
||||
|
||||
/// Edge of a row's thumbnail.
|
||||
const ROW_THUMB: i32 = 40;
|
||||
|
||||
/// A thumbnail surface: an icon, with a picture laid over it that shows once
|
||||
/// the thumbnail has loaded. [`Thumbs`] paints the picture.
|
||||
pub(crate) fn thumb_slot(
|
||||
width: i32,
|
||||
height: i32,
|
||||
icon_size: i32,
|
||||
fit: gtk4::ContentFit,
|
||||
) -> gtk4::Overlay {
|
||||
let fallback = gtk4::Image::builder().pixel_size(icon_size).build();
|
||||
let picture = gtk4::Picture::builder()
|
||||
.width_request(width)
|
||||
.height_request(height)
|
||||
.content_fit(fit)
|
||||
.can_shrink(true)
|
||||
.visible(false)
|
||||
.build();
|
||||
picture.set_overflow(gtk4::Overflow::Hidden);
|
||||
picture.add_css_class("thumb");
|
||||
|
||||
let overlay = gtk4::Overlay::new();
|
||||
overlay.set_size_request(width, height);
|
||||
overlay.set_halign(gtk4::Align::Center);
|
||||
overlay.set_valign(gtk4::Align::Center);
|
||||
overlay.set_child(Some(&fallback));
|
||||
overlay.add_overlay(&picture);
|
||||
overlay
|
||||
}
|
||||
|
||||
/// Show `hit` in a slot from [`thumb_slot`]: its icon at once, and its
|
||||
/// thumbnail when there is one.
|
||||
pub(crate) fn fill_slot(slot: >k4::Overlay, hit: &Hit, thumbs: &Rc<Thumbs>, mountpoint: &Path) {
|
||||
let Some(fallback) = slot.child().and_downcast::<gtk4::Image>() else {
|
||||
return;
|
||||
};
|
||||
let Some(picture) = fallback.next_sibling().and_downcast::<gtk4::Picture>() else {
|
||||
return;
|
||||
};
|
||||
fallback.set_from_gicon(&icon_for(hit.name(), hit.is_dir()));
|
||||
fallback.set_visible(true);
|
||||
picture.set_visible(false);
|
||||
picture.set_paintable(gdk::Paintable::NONE);
|
||||
thumbs.want(hit, mountpoint, &picture);
|
||||
}
|
||||
|
||||
/// The desktop's own full-colour icon for a file name, so results look like
|
||||
/// the user's file manager. Folders are Proton's purple ones while the Proton
|
||||
/// theme is on, as in the app's browser.
|
||||
pub(crate) fn icon_for(name: &str, is_dir: bool) -> gio::Icon {
|
||||
if is_dir {
|
||||
let folder = if proton_theme_active() {
|
||||
"pdfs-proton-folder"
|
||||
} else {
|
||||
"folder"
|
||||
};
|
||||
return gio::ThemedIcon::new(folder).upcast();
|
||||
}
|
||||
let (content_type, _uncertain) = gio::functions::content_type_guess(Some(name), None);
|
||||
gio::functions::content_type_get_icon(&content_type)
|
||||
}
|
||||
|
||||
/// The sync badge of a Drive file, as in the app's browser: pinned, cached,
|
||||
/// or online only. Folders and local files have none.
|
||||
pub(crate) fn badge_for(hit: &Hit) -> Option<(&'static str, &'static str)> {
|
||||
let Hit::Drive(drive) = hit else {
|
||||
return None;
|
||||
};
|
||||
if drive.is_dir {
|
||||
return None;
|
||||
}
|
||||
Some(if drive.pinned {
|
||||
("pdfs-offline-symbolic", "badge-pinned")
|
||||
} else if drive.cached {
|
||||
("pdfs-cached-symbolic", "badge-cached")
|
||||
} else {
|
||||
("pdfs-online-only-symbolic", "badge-cloud")
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_row(
|
||||
hit: &Hit,
|
||||
query: &str,
|
||||
thumbs: &Rc<Thumbs>,
|
||||
mountpoint: &Path,
|
||||
) -> gtk4::ListBoxRow {
|
||||
let row = gtk4::ListBoxRow::new();
|
||||
row.add_css_class("result-row");
|
||||
// Focus stays in the search entry; the list only shows the cursor.
|
||||
row.set_focusable(false);
|
||||
|
||||
let content = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
|
||||
|
||||
let slot = thumb_slot(ROW_THUMB, ROW_THUMB, 32, gtk4::ContentFit::Cover);
|
||||
fill_slot(&slot, hit, thumbs, mountpoint);
|
||||
content.append(&slot);
|
||||
|
||||
let text = gtk4::Box::new(gtk4::Orientation::Vertical, 1);
|
||||
text.set_hexpand(true);
|
||||
text.set_valign(gtk4::Align::Center);
|
||||
|
||||
let name = gtk4::Label::builder()
|
||||
.label(highlight::markup(hit.name(), query))
|
||||
.use_markup(true)
|
||||
.xalign(0.0)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
||||
.build();
|
||||
name.add_css_class("result-name");
|
||||
text.append(&name);
|
||||
|
||||
let location = gtk4::Label::builder()
|
||||
.label(hit.location())
|
||||
.xalign(0.0)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
||||
.build();
|
||||
location.add_css_class("result-location");
|
||||
text.append(&location);
|
||||
content.append(&text);
|
||||
|
||||
let meta = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
||||
meta.set_valign(gtk4::Align::Center);
|
||||
if let Some((icon, class)) = badge_for(hit) {
|
||||
let badge = gtk4::Image::from_icon_name(icon);
|
||||
badge.add_css_class("result-badge");
|
||||
badge.add_css_class(class);
|
||||
meta.append(&badge);
|
||||
}
|
||||
if !hit.is_dir() && hit.size() > 0 {
|
||||
let size = gtk4::Label::new(Some(&human_bytes(hit.size())));
|
||||
size.add_css_class("result-meta");
|
||||
meta.append(&size);
|
||||
}
|
||||
if hit.modified() > 0 {
|
||||
let modified = gtk4::Label::new(Some(&format_age(hit.modified())));
|
||||
modified.add_css_class("result-meta");
|
||||
meta.append(&modified);
|
||||
}
|
||||
content.append(&meta);
|
||||
|
||||
row.set_child(Some(&content));
|
||||
row
|
||||
}
|
||||
|
||||
/// Let a row be dragged into a file manager or an application as a file.
|
||||
/// `dropped` runs once a drop was accepted; a cancelled drag changes nothing.
|
||||
pub(crate) fn attach_drag(row: >k4::ListBoxRow, path: PathBuf, dropped: impl Fn() + 'static) {
|
||||
let source = gtk4::DragSource::new();
|
||||
source.set_actions(gdk::DragAction::COPY);
|
||||
let files = gdk::FileList::from_array(&[gio::File::for_path(&path)]);
|
||||
source.set_content(Some(&gdk::ContentProvider::for_value(&files.to_value())));
|
||||
|
||||
let cancelled = Rc::new(Cell::new(false));
|
||||
let begin = cancelled.clone();
|
||||
source.connect_drag_begin(move |source, _| {
|
||||
begin.set(false);
|
||||
if let Some(widget) = source.widget() {
|
||||
source.set_icon(Some(>k4::WidgetPaintable::new(Some(&widget))), 0, 0);
|
||||
}
|
||||
});
|
||||
let cancel = cancelled.clone();
|
||||
source.connect_drag_cancel(move |_, _, _| {
|
||||
cancel.set(true);
|
||||
false
|
||||
});
|
||||
source.connect_drag_end(move |_, _, _| {
|
||||
if !cancelled.get() {
|
||||
dropped();
|
||||
}
|
||||
});
|
||||
row.add_controller(source);
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
//! Thumbnails for the launcher's rows and preview pane.
|
||||
//!
|
||||
//! The main thread keeps a small LRU of textures, the keys known to have no
|
||||
//! thumbnail, and the pictures waiting for one (as weak references, so a row
|
||||
//! that was rebuilt or dropped keeps nothing alive). Everything that touches
|
||||
//! a file runs on two worker threads, which try in order:
|
||||
//!
|
||||
//! 1. the freedesktop thumbnail cache, valid only when its `Thumb::MTime`
|
||||
//! matches the file's;
|
||||
//! 2. for local images, decoding the file itself, and writing the result back
|
||||
//! to that cache for the next time and for every other application;
|
||||
//! 3. for Drive images, the daemon's `FileThumbs`, batched after a short quiet
|
||||
//! period.
|
||||
//!
|
||||
//! Workers hand back `gdk::Texture`s, never a `Pixbuf`, which may not cross
|
||||
//! threads. Each job carries the render epoch it was queued in, and workers
|
||||
//! skip jobs a newer query has made pointless.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::{Rc, Weak};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
use gtk4::gdk_pixbuf::Pixbuf;
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{gdk, glib};
|
||||
|
||||
use pdfs_core::control::{FileThumbRequest, Request, Response, is_thumbnail_image_name};
|
||||
|
||||
use crate::{Hit, spawn_request};
|
||||
|
||||
/// Decoded textures kept in memory. A launcher shows a few dozen rows, so
|
||||
/// this covers several searches back.
|
||||
const TEXTURE_CACHE_MAX: usize = 128;
|
||||
const MISSING_MAX: usize = 1024;
|
||||
const WORKERS: usize = 2;
|
||||
|
||||
/// Edge of the thumbnails this loader decodes and writes: the freedesktop
|
||||
/// "large" size.
|
||||
const THUMB_SIZE: i32 = 256;
|
||||
|
||||
/// Local images above this size are not decoded here; a thumbnail is not
|
||||
/// worth reading that much.
|
||||
const MAX_SOURCE_BYTES: u64 = 64 * 1024 * 1024;
|
||||
|
||||
/// Quiet period before Drive misses go to the daemon as one batch, so typing
|
||||
/// a word asks once for the final result set.
|
||||
const DAEMON_DEBOUNCE: Duration = Duration::from_millis(150);
|
||||
const DAEMON_RETRY: Duration = Duration::from_secs(1);
|
||||
const DAEMON_BATCH: usize = 32;
|
||||
|
||||
/// Where a job finds its image.
|
||||
enum Source {
|
||||
/// A file on this machine.
|
||||
Local(PathBuf),
|
||||
/// A Drive file: the thumbnail cache is keyed by its mounted path, and
|
||||
/// the daemon is asked on a miss.
|
||||
Drive {
|
||||
mounted: PathBuf,
|
||||
request: FileThumbRequest,
|
||||
},
|
||||
/// A thumbnail the daemon made, in its own cache.
|
||||
Daemon(PathBuf),
|
||||
}
|
||||
|
||||
struct Job {
|
||||
epoch: u64,
|
||||
key: String,
|
||||
source: Source,
|
||||
}
|
||||
|
||||
enum Outcome {
|
||||
Texture(gdk::Texture),
|
||||
Missing,
|
||||
/// Not in the thumbnail cache; the daemon has to make it.
|
||||
Daemon(FileThumbRequest),
|
||||
/// Queued for an older render; handed back in case it is wanted again.
|
||||
Skipped(Source),
|
||||
}
|
||||
|
||||
struct Done {
|
||||
key: String,
|
||||
outcome: Outcome,
|
||||
}
|
||||
|
||||
pub(crate) struct Thumbs {
|
||||
socket: PathBuf,
|
||||
epoch: Arc<AtomicU64>,
|
||||
jobs: async_channel::Sender<Job>,
|
||||
|
||||
textures: RefCell<HashMap<String, gdk::Texture>>,
|
||||
/// Least recently used first.
|
||||
order: RefCell<VecDeque<String>>,
|
||||
missing: RefCell<HashSet<String>>,
|
||||
missing_order: RefCell<VecDeque<String>>,
|
||||
waiters: RefCell<HashMap<String, Vec<glib::WeakRef<gtk4::Picture>>>>,
|
||||
/// Keys with a worker job or a daemon request outstanding.
|
||||
working: RefCell<HashSet<String>>,
|
||||
|
||||
/// Drive misses waiting for the next batch, by key.
|
||||
queued: RefCell<HashMap<String, FileThumbRequest>>,
|
||||
/// Drive items the daemon is working on under `generation`, by key.
|
||||
sent: RefCell<HashMap<String, FileThumbRequest>>,
|
||||
generation: Cell<u64>,
|
||||
reserving: Cell<bool>,
|
||||
inflight: Cell<bool>,
|
||||
flush: RefCell<Option<glib::SourceId>>,
|
||||
}
|
||||
|
||||
impl Thumbs {
|
||||
pub(crate) fn new(socket: PathBuf) -> Rc<Self> {
|
||||
let epoch = Arc::new(AtomicU64::new(0));
|
||||
let (jobs, inbox) = async_channel::unbounded::<Job>();
|
||||
let (done, results) = async_channel::unbounded::<Done>();
|
||||
for n in 0..WORKERS {
|
||||
let inbox = inbox.clone();
|
||||
let done = done.clone();
|
||||
let epoch = epoch.clone();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name(format!("prompt-thumbs-{n}"))
|
||||
.spawn(move || work(n, &inbox, &done, &epoch));
|
||||
if let Err(e) = spawned {
|
||||
tracing::warn!("cannot start a thumbnail worker: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
let thumbs = Rc::new(Self {
|
||||
socket,
|
||||
epoch,
|
||||
jobs,
|
||||
textures: RefCell::new(HashMap::new()),
|
||||
order: RefCell::new(VecDeque::new()),
|
||||
missing: RefCell::new(HashSet::new()),
|
||||
missing_order: RefCell::new(VecDeque::new()),
|
||||
waiters: RefCell::new(HashMap::new()),
|
||||
working: RefCell::new(HashSet::new()),
|
||||
queued: RefCell::new(HashMap::new()),
|
||||
sent: RefCell::new(HashMap::new()),
|
||||
generation: Cell::new(0),
|
||||
reserving: Cell::new(false),
|
||||
inflight: Cell::new(false),
|
||||
flush: RefCell::new(None),
|
||||
});
|
||||
let weak = Rc::downgrade(&thumbs);
|
||||
glib::spawn_future_local(async move {
|
||||
while let Ok(done) = results.recv().await {
|
||||
let Some(thumbs) = weak.upgrade() else {
|
||||
break;
|
||||
};
|
||||
thumbs.finish(done);
|
||||
}
|
||||
});
|
||||
thumbs
|
||||
}
|
||||
|
||||
/// Paint `hit`'s thumbnail into `picture` now if it is known, or as soon
|
||||
/// as it is. Folders and non-images keep the slot's icon.
|
||||
pub(crate) fn want(self: &Rc<Self>, hit: &Hit, mountpoint: &Path, picture: >k4::Picture) {
|
||||
let key = hit.thumb_key();
|
||||
// The widget name binds the picture to one key: a late reply for
|
||||
// another hit must not paint into it.
|
||||
picture.set_widget_name(&key);
|
||||
if hit.is_dir() || !is_thumbnail_image_name(hit.name()) {
|
||||
return;
|
||||
}
|
||||
if matches!(hit, Hit::Drive(drive) if drive.uid.is_empty()) {
|
||||
return;
|
||||
}
|
||||
let cached = self.textures.borrow().get(&key).cloned();
|
||||
if let Some(texture) = cached {
|
||||
self.touch(&key);
|
||||
paint(picture, &key, &texture);
|
||||
return;
|
||||
}
|
||||
if self.missing.borrow().contains(&key) {
|
||||
return;
|
||||
}
|
||||
self.waiters
|
||||
.borrow_mut()
|
||||
.entry(key.clone())
|
||||
.or_default()
|
||||
.push(picture.downgrade());
|
||||
if !self.working.borrow_mut().insert(key.clone()) {
|
||||
return;
|
||||
}
|
||||
let source = match hit {
|
||||
Hit::Local(local) => Source::Local(PathBuf::from(&local.path)),
|
||||
Hit::Drive(drive) => Source::Drive {
|
||||
mounted: hit.fs_path(mountpoint),
|
||||
request: FileThumbRequest {
|
||||
uid: drive.uid.clone(),
|
||||
modified: drive.modified,
|
||||
name: drive.name.clone(),
|
||||
},
|
||||
},
|
||||
};
|
||||
self.dispatch(key, source);
|
||||
}
|
||||
|
||||
/// The rows are about to be rebuilt: forget their pictures, and let the
|
||||
/// workers skip what was queued for them.
|
||||
pub(crate) fn begin_render(&self) {
|
||||
self.epoch.fetch_add(1, Ordering::SeqCst);
|
||||
self.waiters.borrow_mut().clear();
|
||||
}
|
||||
|
||||
/// The rows are rebuilt and have asked for their thumbnails. Drive work
|
||||
/// for hits that are gone is dropped, and cancelled at the daemon when it
|
||||
/// had already been sent.
|
||||
pub(crate) fn end_render(self: &Rc<Self>) {
|
||||
let wanted: HashSet<String> = self.waiters.borrow().keys().cloned().collect();
|
||||
let mut dropped = Vec::new();
|
||||
self.queued.borrow_mut().retain(|key, _| {
|
||||
let keep = wanted.contains(key);
|
||||
if !keep {
|
||||
dropped.push(key.clone());
|
||||
}
|
||||
keep
|
||||
});
|
||||
let stale = self.sent.borrow().keys().any(|key| !wanted.contains(key));
|
||||
if stale {
|
||||
self.cancel_daemon();
|
||||
// Still-wanted items go back into the next batch, under a fresh
|
||||
// generation.
|
||||
let sent: Vec<(String, FileThumbRequest)> = self.sent.borrow_mut().drain().collect();
|
||||
for (key, request) in sent {
|
||||
if wanted.contains(&key) {
|
||||
self.queued.borrow_mut().insert(key, request);
|
||||
} else {
|
||||
dropped.push(key);
|
||||
}
|
||||
}
|
||||
self.schedule(DAEMON_DEBOUNCE);
|
||||
}
|
||||
let mut working = self.working.borrow_mut();
|
||||
for key in dropped {
|
||||
working.remove(&key);
|
||||
}
|
||||
}
|
||||
|
||||
/// The launcher was dismissed: stop everything.
|
||||
pub(crate) fn cancel_all(&self) {
|
||||
self.epoch.fetch_add(1, Ordering::SeqCst);
|
||||
self.waiters.borrow_mut().clear();
|
||||
self.working.borrow_mut().clear();
|
||||
self.queued.borrow_mut().clear();
|
||||
self.sent.borrow_mut().clear();
|
||||
if let Some(source) = self.flush.borrow_mut().take() {
|
||||
source.remove();
|
||||
}
|
||||
self.cancel_daemon();
|
||||
}
|
||||
|
||||
fn dispatch(&self, key: String, source: Source) {
|
||||
let job = Job {
|
||||
epoch: self.epoch.load(Ordering::SeqCst),
|
||||
key: key.clone(),
|
||||
source,
|
||||
};
|
||||
if self.jobs.try_send(job).is_err() {
|
||||
self.working.borrow_mut().remove(&key);
|
||||
}
|
||||
}
|
||||
|
||||
fn wanted(&self, key: &str) -> bool {
|
||||
self.waiters.borrow().contains_key(key)
|
||||
}
|
||||
|
||||
fn finish(self: &Rc<Self>, done: Done) {
|
||||
let Done { key, outcome } = done;
|
||||
match outcome {
|
||||
Outcome::Texture(texture) => {
|
||||
self.working.borrow_mut().remove(&key);
|
||||
self.store_texture(&key, texture.clone());
|
||||
let waiters = self.waiters.borrow_mut().remove(&key);
|
||||
for picture in waiters.into_iter().flatten() {
|
||||
if let Some(picture) = picture.upgrade() {
|
||||
paint(&picture, &key, &texture);
|
||||
}
|
||||
}
|
||||
}
|
||||
Outcome::Missing => {
|
||||
self.working.borrow_mut().remove(&key);
|
||||
self.store_missing(key.clone());
|
||||
self.waiters.borrow_mut().remove(&key);
|
||||
}
|
||||
Outcome::Daemon(request) => {
|
||||
if self.wanted(&key) {
|
||||
self.queued.borrow_mut().insert(key, request);
|
||||
self.schedule(DAEMON_DEBOUNCE);
|
||||
} else {
|
||||
self.working.borrow_mut().remove(&key);
|
||||
}
|
||||
}
|
||||
Outcome::Skipped(source) => {
|
||||
// A newer render may want the same image again.
|
||||
if self.wanted(&key) {
|
||||
self.dispatch(key, source);
|
||||
} else {
|
||||
self.working.borrow_mut().remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn touch(&self, key: &str) {
|
||||
let mut order = self.order.borrow_mut();
|
||||
if let Some(at) = order.iter().position(|held| held == key) {
|
||||
order.remove(at);
|
||||
order.push_back(key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn store_texture(&self, key: &str, texture: gdk::Texture) {
|
||||
let mut textures = self.textures.borrow_mut();
|
||||
if textures.insert(key.to_string(), texture).is_none() {
|
||||
self.order.borrow_mut().push_back(key.to_string());
|
||||
} else {
|
||||
drop(textures);
|
||||
self.touch(key);
|
||||
return;
|
||||
}
|
||||
let mut order = self.order.borrow_mut();
|
||||
while order.len() > TEXTURE_CACHE_MAX {
|
||||
if let Some(old) = order.pop_front() {
|
||||
textures.remove(&old);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn store_missing(&self, key: String) {
|
||||
let mut missing = self.missing.borrow_mut();
|
||||
let mut order = self.missing_order.borrow_mut();
|
||||
if missing.insert(key.clone()) {
|
||||
order.push_back(key);
|
||||
}
|
||||
while order.len() > MISSING_MAX {
|
||||
if let Some(old) = order.pop_front() {
|
||||
missing.remove(&old);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the daemon's work for this launcher. The generation is shared
|
||||
/// with the app, so this is only sent when something was asked for.
|
||||
fn cancel_daemon(&self) {
|
||||
let generation = self.generation.replace(0);
|
||||
if generation != 0 {
|
||||
// The reply is not needed; `spawn_request` owns the round-trip.
|
||||
drop(spawn_request(
|
||||
self.socket.clone(),
|
||||
Request::CancelFileThumbs {
|
||||
generation: generation.saturating_add(1),
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule(self: &Rc<Self>, delay: Duration) {
|
||||
if let Some(source) = self.flush.borrow_mut().take() {
|
||||
source.remove();
|
||||
}
|
||||
let weak = Rc::downgrade(self);
|
||||
let source = glib::timeout_add_local_once(delay, move || {
|
||||
if let Some(thumbs) = weak.upgrade() {
|
||||
thumbs.flush.borrow_mut().take();
|
||||
thumbs.send_batch();
|
||||
}
|
||||
});
|
||||
*self.flush.borrow_mut() = Some(source);
|
||||
}
|
||||
|
||||
fn send_batch(self: &Rc<Self>) {
|
||||
if self.inflight.get() || self.queued.borrow().is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.generation.get() == 0 {
|
||||
self.reserve();
|
||||
return;
|
||||
}
|
||||
let batch: Vec<(String, FileThumbRequest)> = {
|
||||
let mut queued = self.queued.borrow_mut();
|
||||
let keys: Vec<String> = queued.keys().take(DAEMON_BATCH).cloned().collect();
|
||||
keys.into_iter()
|
||||
.filter_map(|key| queued.remove_entry(&key))
|
||||
.collect()
|
||||
};
|
||||
self.sent.borrow_mut().extend(batch.iter().cloned());
|
||||
let generation = self.generation.get();
|
||||
self.inflight.set(true);
|
||||
let rx = spawn_request(
|
||||
self.socket.clone(),
|
||||
Request::FileThumbs {
|
||||
items: batch.iter().map(|(_, request)| request.clone()).collect(),
|
||||
generation,
|
||||
},
|
||||
);
|
||||
let weak = Rc::downgrade(self);
|
||||
glib::spawn_future_local(async move {
|
||||
let reply = rx.recv().await;
|
||||
let Some(thumbs) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
thumbs.inflight.set(false);
|
||||
thumbs.receive(generation, batch, reply);
|
||||
if !thumbs.queued.borrow().is_empty() {
|
||||
thumbs.schedule(Duration::ZERO);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn receive(
|
||||
self: &Rc<Self>,
|
||||
generation: u64,
|
||||
batch: Vec<(String, FileThumbRequest)>,
|
||||
reply: Result<Result<Response, String>, async_channel::RecvError>,
|
||||
) {
|
||||
// Cancelled while in flight: `end_render` or `cancel_all` already
|
||||
// re-queued what is still wanted. A finished thumbnail is still worth
|
||||
// painting.
|
||||
let current = generation == self.generation.get();
|
||||
match reply {
|
||||
Ok(Ok(Response::Thumbs { items })) => {
|
||||
let mut by_uid: HashMap<String, (String, FileThumbRequest)> = batch
|
||||
.into_iter()
|
||||
.map(|(key, request)| (request.uid.clone(), (key, request)))
|
||||
.collect();
|
||||
let mut again = Vec::new();
|
||||
for item in items {
|
||||
let Some((key, request)) = by_uid.remove(&item.uid) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(path) = item.path {
|
||||
if current {
|
||||
self.sent.borrow_mut().remove(&key);
|
||||
}
|
||||
if self.wanted(&key) {
|
||||
self.working.borrow_mut().insert(key.clone());
|
||||
self.dispatch(key, Source::Daemon(PathBuf::from(path)));
|
||||
} else {
|
||||
self.working.borrow_mut().remove(&key);
|
||||
}
|
||||
} else if !current {
|
||||
continue;
|
||||
} else if item.pending {
|
||||
again.push((key, request));
|
||||
} else {
|
||||
self.sent.borrow_mut().remove(&key);
|
||||
self.working.borrow_mut().remove(&key);
|
||||
self.store_missing(key.clone());
|
||||
self.waiters.borrow_mut().remove(&key);
|
||||
}
|
||||
}
|
||||
if current {
|
||||
again.extend(by_uid.into_values());
|
||||
self.retry(again);
|
||||
}
|
||||
}
|
||||
Ok(Ok(Response::FileThumbsStale)) if current => {
|
||||
self.generation.set(0);
|
||||
self.retry(batch);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) if current => {
|
||||
tracing::debug!("launcher thumbnails failed: {message}");
|
||||
self.retry(batch);
|
||||
}
|
||||
_ if current => self.retry(batch),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask again for items the daemon is still making, after a pause.
|
||||
fn retry(self: &Rc<Self>, items: Vec<(String, FileThumbRequest)>) {
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
let weak = Rc::downgrade(self);
|
||||
glib::timeout_add_local_once(DAEMON_RETRY, move || {
|
||||
let Some(thumbs) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
for (key, request) in items {
|
||||
// Gone from `sent` means cancelled or already answered.
|
||||
if thumbs.sent.borrow_mut().remove(&key).is_some() && thumbs.wanted(&key) {
|
||||
thumbs.queued.borrow_mut().insert(key, request);
|
||||
} else {
|
||||
thumbs.working.borrow_mut().remove(&key);
|
||||
}
|
||||
}
|
||||
thumbs.send_batch();
|
||||
});
|
||||
}
|
||||
|
||||
fn reserve(self: &Rc<Self>) {
|
||||
if self.reserving.replace(true) {
|
||||
return;
|
||||
}
|
||||
let rx = spawn_request(self.socket.clone(), Request::ReserveFileThumbGeneration);
|
||||
let weak: Weak<Self> = Rc::downgrade(self);
|
||||
glib::spawn_future_local(async move {
|
||||
let reply = rx.recv().await;
|
||||
let Some(thumbs) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
thumbs.reserving.set(false);
|
||||
match reply {
|
||||
Ok(Ok(Response::FileThumbGeneration { generation })) if generation != 0 => {
|
||||
thumbs.generation.set(generation);
|
||||
thumbs.send_batch();
|
||||
}
|
||||
_ => thumbs.schedule(DAEMON_RETRY),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Show `texture` in `picture` if the picture still belongs to `key`, and hide
|
||||
/// the icon underneath it.
|
||||
fn paint(picture: >k4::Picture, key: &str, texture: &gdk::Texture) {
|
||||
if picture.widget_name() != key {
|
||||
return;
|
||||
}
|
||||
picture.set_paintable(Some(texture));
|
||||
picture.set_visible(true);
|
||||
if let Some(overlay) = picture.parent().and_downcast::<gtk4::Overlay>()
|
||||
&& let Some(fallback) = overlay.child()
|
||||
{
|
||||
fallback.set_visible(false);
|
||||
}
|
||||
}
|
||||
|
||||
fn work(
|
||||
index: usize,
|
||||
inbox: &async_channel::Receiver<Job>,
|
||||
done: &async_channel::Sender<Done>,
|
||||
epoch: &AtomicU64,
|
||||
) {
|
||||
while let Ok(job) = inbox.recv_blocking() {
|
||||
// A daemon thumbnail is already made; decoding it is cheap and the
|
||||
// texture is cached for the next render.
|
||||
let stale =
|
||||
!matches!(job.source, Source::Daemon(_)) && job.epoch < epoch.load(Ordering::SeqCst);
|
||||
let outcome = if stale {
|
||||
Outcome::Skipped(job.source)
|
||||
} else {
|
||||
load(index, job.source)
|
||||
};
|
||||
if done
|
||||
.send_blocking(Done {
|
||||
key: job.key,
|
||||
outcome,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load(worker: usize, source: Source) -> Outcome {
|
||||
match source {
|
||||
Source::Local(path) => {
|
||||
let Ok(meta) = std::fs::metadata(&path) else {
|
||||
return Outcome::Missing;
|
||||
};
|
||||
let mtime = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|at| at.duration_since(UNIX_EPOCH).ok())
|
||||
.map_or(0, |at| at.as_secs() as i64);
|
||||
if let Some(pixbuf) = cached(&path, mtime) {
|
||||
return texture(&pixbuf);
|
||||
}
|
||||
if meta.len() > MAX_SOURCE_BYTES {
|
||||
return Outcome::Missing;
|
||||
}
|
||||
let Some((pixbuf, scaled)) = decode(&path) else {
|
||||
return Outcome::Missing;
|
||||
};
|
||||
// An image that already fits is its own thumbnail.
|
||||
if scaled && let Err(e) = write_cached(worker, &path, mtime, &pixbuf) {
|
||||
tracing::debug!("cannot cache a thumbnail of {}: {e}", path.display());
|
||||
}
|
||||
texture(&pixbuf)
|
||||
}
|
||||
Source::Drive { mounted, request } => match cached(&mounted, request.modified) {
|
||||
Some(pixbuf) => texture(&pixbuf),
|
||||
None => Outcome::Daemon(request),
|
||||
},
|
||||
Source::Daemon(path) => match gdk::Texture::from_filename(&path) {
|
||||
Ok(texture) => Outcome::Texture(texture),
|
||||
Err(e) => {
|
||||
tracing::debug!("cannot decode {}: {e}", path.display());
|
||||
Outcome::Missing
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `$XDG_CACHE_HOME/thumbnails`.
|
||||
fn thumbnail_root() -> PathBuf {
|
||||
glib::user_cache_dir().join("thumbnails")
|
||||
}
|
||||
|
||||
/// The URI and its MD5, which name a file's entry in the thumbnail cache.
|
||||
fn cache_name(path: &Path) -> Option<(String, String)> {
|
||||
let uri = glib::filename_to_uri(path, None).ok()?.to_string();
|
||||
let md5 = glib::compute_checksum_for_data(glib::ChecksumType::Md5, uri.as_bytes())?.to_string();
|
||||
Some((uri, md5))
|
||||
}
|
||||
|
||||
/// A thumbnail from the shared cache, if one was made for this version of
|
||||
/// the file.
|
||||
fn cached(path: &Path, mtime: i64) -> Option<Pixbuf> {
|
||||
let (_, md5) = cache_name(path)?;
|
||||
let root = thumbnail_root();
|
||||
["large", "x-large", "normal"].iter().find_map(|size| {
|
||||
let pixbuf = Pixbuf::from_file(root.join(size).join(format!("{md5}.png"))).ok()?;
|
||||
let stamp = pixbuf.option("tEXt::Thumb::MTime")?;
|
||||
(stamp.parse::<i64>().ok()? == mtime).then_some(pixbuf)
|
||||
})
|
||||
}
|
||||
|
||||
/// Decode a local image at most [`THUMB_SIZE`] on its longer edge, upright.
|
||||
/// The bool says whether it had to be scaled down.
|
||||
fn decode(path: &Path) -> Option<(Pixbuf, bool)> {
|
||||
let (_, width, height) = Pixbuf::file_info(path)?;
|
||||
let scaled = width > THUMB_SIZE || height > THUMB_SIZE;
|
||||
let pixbuf = if scaled {
|
||||
Pixbuf::from_file_at_scale(path, THUMB_SIZE, THUMB_SIZE, true)
|
||||
} else {
|
||||
Pixbuf::from_file(path)
|
||||
}
|
||||
.ok()?;
|
||||
let pixbuf = pixbuf.apply_embedded_orientation().unwrap_or(pixbuf);
|
||||
Some((pixbuf, scaled))
|
||||
}
|
||||
|
||||
/// Store a thumbnail as the freedesktop spec asks: in `large/`, with
|
||||
/// `Thumb::URI` and `Thumb::MTime`, readable only by the user, and written
|
||||
/// through a temporary file so another reader never sees half of it.
|
||||
fn write_cached(worker: usize, path: &Path, mtime: i64, pixbuf: &Pixbuf) -> std::io::Result<()> {
|
||||
let (uri, md5) = cache_name(path).ok_or_else(|| std::io::Error::other("no file URI"))?;
|
||||
let dir = thumbnail_root().join("large");
|
||||
std::fs::DirBuilder::new()
|
||||
.recursive(true)
|
||||
.mode(0o700)
|
||||
.create(&dir)?;
|
||||
let mtime = mtime.to_string();
|
||||
let png = pixbuf
|
||||
.save_to_bufferv(
|
||||
"png",
|
||||
&[
|
||||
("tEXt::Thumb::URI", uri.as_str()),
|
||||
("tEXt::Thumb::MTime", mtime.as_str()),
|
||||
],
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
let tmp = dir.join(format!(
|
||||
"{md5}.png.pdfs-{}-{worker}.tmp",
|
||||
std::process::id()
|
||||
));
|
||||
let written = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp)
|
||||
.and_then(|mut file| file.write_all(&png))
|
||||
.and_then(|()| std::fs::rename(&tmp, dir.join(format!("{md5}.png"))));
|
||||
if written.is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
/// A `Pixbuf` may not leave its thread; its pixels, as a memory texture, can.
|
||||
fn texture(pixbuf: &Pixbuf) -> Outcome {
|
||||
let format = if pixbuf.has_alpha() {
|
||||
gdk::MemoryFormat::R8g8b8a8
|
||||
} else {
|
||||
gdk::MemoryFormat::R8g8b8
|
||||
};
|
||||
let texture = gdk::MemoryTexture::new(
|
||||
pixbuf.width(),
|
||||
pixbuf.height(),
|
||||
format,
|
||||
&pixbuf.read_pixel_bytes(),
|
||||
pixbuf.rowstride() as usize,
|
||||
);
|
||||
Outcome::Texture(texture.upcast())
|
||||
}
|
||||
@@ -198,6 +198,18 @@ pub(crate) fn pending_summary(uploads: u64, changes: u64) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a byte count as a short binary-unit string (e.g. `1.2 GiB`, or
|
||||
/// `512 bytes` below one KiB).
|
||||
///
|
||||
/// GLib does the formatting, so the units, the plural of "bytes" and the
|
||||
/// decimal separator follow the user's language from GLib's own catalog. GLib
|
||||
/// separates number and unit with a no-break space; it is turned back into a
|
||||
/// plain space so the text matches what the rest of the UI builds around it.
|
||||
pub(crate) fn human_bytes(bytes: u64) -> String {
|
||||
gtk4::glib::format_size_full(bytes, gtk4::glib::FormatSizeFlags::IEC_UNITS)
|
||||
.replace('\u{a0}', " ")
|
||||
}
|
||||
|
||||
/// gettext takes the count as `u32`. Counts past that pick the same form as
|
||||
/// the largest one, which is the plural in every shipped language.
|
||||
fn plural_count(n: u64) -> u32 {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use ksni::blocking::TrayMethods;
|
||||
@@ -21,14 +21,17 @@ use ksni::menu::StandardItem;
|
||||
use ksni::{MenuItem, Status, ToolTip, Tray};
|
||||
use pdfs_core::auth;
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{JobItem, Request, Response, TransferDirection, TransferItem, send};
|
||||
use pdfs_core::control::{
|
||||
Event, Feed, JobItem, Request, Response, TransferDirection, TransferItem, follow, send,
|
||||
};
|
||||
use pdfs_core::service;
|
||||
|
||||
mod i18n;
|
||||
use i18n::{gettext, gettext_f, ngettext_f};
|
||||
|
||||
/// How often the tray re-polls the daemon to refresh its menu.
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(3);
|
||||
/// How long the tray lets a burst of events arrive before it repaints. Progress
|
||||
/// moves several times a second, which is more than a tray menu can show.
|
||||
const COALESCE: Duration = Duration::from_secs(1);
|
||||
|
||||
/// The one word the icon says about the drive, derived from each poll.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -53,7 +56,7 @@ impl Phase {
|
||||
fn icon(self) -> &'static str {
|
||||
match self {
|
||||
Phase::Synced => "folder-remote-symbolic",
|
||||
Phase::Syncing => "emblem-synchronizing-symbolic",
|
||||
Phase::Syncing => "view-refresh-symbolic",
|
||||
Phase::Paused => "media-playback-pause-symbolic",
|
||||
Phase::Offline => "network-offline-symbolic",
|
||||
Phase::Attention => "dialog-warning-symbolic",
|
||||
@@ -139,72 +142,20 @@ struct DriveTray {
|
||||
/// menu can still say "Logged in …" / "Not logged in" when no mount is running.
|
||||
fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||
match send(socket, &Request::Status) {
|
||||
Ok(Response::Status {
|
||||
mountpoint,
|
||||
pinned,
|
||||
online,
|
||||
pending_uploads,
|
||||
pending_changes,
|
||||
failing_ops,
|
||||
paused,
|
||||
..
|
||||
}) => {
|
||||
// Same daemon is up, so a cheap follow-up poll gives the sync line.
|
||||
Ok(status @ Response::Status { .. }) => {
|
||||
// Same daemon is up, so a cheap follow-up request gives the sync line.
|
||||
let sync = match send(socket, &Request::GetQueueStatus) {
|
||||
Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs),
|
||||
_ => String::new(),
|
||||
};
|
||||
let queued = i18n::pending_summary(pending_uploads, pending_changes);
|
||||
let phase = phase_of(
|
||||
paused,
|
||||
failing_ops,
|
||||
online,
|
||||
queued.is_some() || !sync.is_empty(),
|
||||
);
|
||||
DriveState {
|
||||
line: match (online, queued) {
|
||||
_ if paused => gettext("Sync paused"),
|
||||
_ if failing_ops > 0 => ngettext_f(
|
||||
"{n} change needs attention",
|
||||
"{n} changes need attention",
|
||||
failing_ops,
|
||||
&[],
|
||||
),
|
||||
// Translators: {mountpoint} is a folder path; {n} is the number of pinned items.
|
||||
(true, None) => ngettext_f(
|
||||
"Mounted at {mountpoint} ({n} pinned)",
|
||||
"Mounted at {mountpoint} ({n} pinned)",
|
||||
pinned as u64,
|
||||
&[("mountpoint", &mountpoint)],
|
||||
),
|
||||
// Translators: {queued} is a queue summary such as "3 uploads queued"; {n} is the number of pinned items.
|
||||
(true, Some(q)) => ngettext_f(
|
||||
"Syncing — {queued} ({n} pinned)",
|
||||
"Syncing — {queued} ({n} pinned)",
|
||||
pinned as u64,
|
||||
&[("queued", &q)],
|
||||
),
|
||||
// Translators: {n} is the number of pinned items.
|
||||
(false, None) => ngettext_f(
|
||||
"Offline — cached files only ({n} pinned)",
|
||||
"Offline — cached files only ({n} pinned)",
|
||||
pinned as u64,
|
||||
&[],
|
||||
),
|
||||
// Translators: {queued} is a queue summary such as "3 uploads queued".
|
||||
(false, Some(q)) => gettext_f("Offline — {queued}", &[("queued", &q)]),
|
||||
},
|
||||
phase,
|
||||
mounted: true,
|
||||
paused,
|
||||
mountpoint: PathBuf::from(mountpoint),
|
||||
sync,
|
||||
failing: failing_ops,
|
||||
}
|
||||
status_state(&status, sync, default_mountpoint)
|
||||
}
|
||||
// Socket answered but with something unexpected — treat as up but odd.
|
||||
Ok(_) => DriveState {
|
||||
line: gettext("Mount: unexpected daemon response"),
|
||||
Ok(_) => unexpected_reply(default_mountpoint),
|
||||
// The daemon is up but stuck: say so rather than "not connected", and
|
||||
// keep the mount actions, which still work on the folder itself.
|
||||
Err(e) if e.is_timeout() => DriveState {
|
||||
line: gettext("Proton Drive isn't responding"),
|
||||
phase: Phase::Attention,
|
||||
mounted: true,
|
||||
mountpoint: default_mountpoint.to_path_buf(),
|
||||
@@ -218,7 +169,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||
Ok(s) => (
|
||||
// Translators: {username} is the Proton account name.
|
||||
gettext_f(
|
||||
"Logged in as {username} — not mounted",
|
||||
"Signed in as {username} — not connected",
|
||||
&[("username", &s.username)],
|
||||
),
|
||||
Phase::Disconnected,
|
||||
@@ -243,6 +194,86 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the tray says when the daemon answers with something it did not ask
|
||||
/// for: up, but odd.
|
||||
fn unexpected_reply(default_mountpoint: &Path) -> DriveState {
|
||||
DriveState {
|
||||
line: gettext("Proton Drive sent an unexpected reply"),
|
||||
phase: Phase::Attention,
|
||||
mounted: true,
|
||||
mountpoint: default_mountpoint.to_path_buf(),
|
||||
sync: String::new(),
|
||||
paused: false,
|
||||
failing: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The state a running daemon's [`Response::Status`] describes, with `sync`
|
||||
/// saying what it is moving right now.
|
||||
fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> DriveState {
|
||||
let Response::Status {
|
||||
mountpoint,
|
||||
pinned,
|
||||
online,
|
||||
pending_uploads,
|
||||
pending_changes,
|
||||
failing_ops,
|
||||
paused,
|
||||
..
|
||||
} = status
|
||||
else {
|
||||
return unexpected_reply(default_mountpoint);
|
||||
};
|
||||
let (pinned, online, failing_ops, paused) = (*pinned, *online, *failing_ops, *paused);
|
||||
let queued = i18n::pending_summary(*pending_uploads, *pending_changes);
|
||||
let phase = phase_of(
|
||||
paused,
|
||||
failing_ops,
|
||||
online,
|
||||
queued.is_some() || !sync.is_empty(),
|
||||
);
|
||||
DriveState {
|
||||
line: match (online, queued) {
|
||||
_ if paused => gettext("Sync paused"),
|
||||
_ if failing_ops > 0 => ngettext_f(
|
||||
"{n} change needs attention",
|
||||
"{n} changes need attention",
|
||||
failing_ops,
|
||||
&[],
|
||||
),
|
||||
// Translators: {mountpoint} is a folder path; {n} is the number of pinned items.
|
||||
(true, None) => ngettext_f(
|
||||
"Up to date in {mountpoint} ({n} pinned)",
|
||||
"Up to date in {mountpoint} ({n} pinned)",
|
||||
pinned as u64,
|
||||
&[("mountpoint", mountpoint)],
|
||||
),
|
||||
// Translators: {queued} is a queue summary such as "3 uploads queued"; {n} is the number of pinned items.
|
||||
(true, Some(q)) => ngettext_f(
|
||||
"Syncing — {queued} ({n} pinned)",
|
||||
"Syncing — {queued} ({n} pinned)",
|
||||
pinned as u64,
|
||||
&[("queued", &q)],
|
||||
),
|
||||
// Translators: {n} is the number of pinned items.
|
||||
(false, None) => ngettext_f(
|
||||
"Offline — cached files only ({n} pinned)",
|
||||
"Offline — cached files only ({n} pinned)",
|
||||
pinned as u64,
|
||||
&[],
|
||||
),
|
||||
// Translators: {queued} is a queue summary such as "3 uploads queued".
|
||||
(false, Some(q)) => gettext_f("Offline — {queued}", &[("queued", &q)]),
|
||||
},
|
||||
phase,
|
||||
mounted: true,
|
||||
paused,
|
||||
mountpoint: PathBuf::from(mountpoint),
|
||||
sync,
|
||||
failing: failing_ops,
|
||||
}
|
||||
}
|
||||
|
||||
/// The phase a running mount is in. A pause is the user's own doing and says
|
||||
/// so first; failures outrank everything else the daemon reports.
|
||||
fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase {
|
||||
@@ -490,13 +521,53 @@ fn main() -> Result<()> {
|
||||
.spawn()
|
||||
.context("start tray service")?;
|
||||
|
||||
// Poll the daemon forever, pushing each fresh snapshot into the tray so the
|
||||
// Follow the daemon forever, pushing each fresh state into the tray so the
|
||||
// menu reflects mount/login changes made elsewhere (e.g. via the CLI).
|
||||
loop {
|
||||
std::thread::sleep(POLL_INTERVAL);
|
||||
let st = poll_state(&socket, &default_mountpoint);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let feed_socket = socket.clone();
|
||||
std::thread::spawn(move || follow(&feed_socket, |feed| tx.send(feed).is_ok()));
|
||||
let mut heard = Heard::default();
|
||||
while let Ok(feed) = rx.recv() {
|
||||
heard.take(feed);
|
||||
let until = Instant::now() + COALESCE;
|
||||
while let Ok(feed) = rx.recv_timeout(until.saturating_duration_since(Instant::now())) {
|
||||
heard.take(feed);
|
||||
}
|
||||
let st = match &heard.status {
|
||||
Some(status) => status_state(
|
||||
status,
|
||||
sync_line(&heard.items, &heard.jobs),
|
||||
&default_mountpoint,
|
||||
),
|
||||
// Lost the daemon: ask once, which describes the sign-in instead.
|
||||
None => poll_state(&socket, &default_mountpoint),
|
||||
};
|
||||
handle.update(move |t: &mut DriveTray| t.state = st);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What the tray has heard from the daemon's event feed.
|
||||
#[derive(Default)]
|
||||
struct Heard {
|
||||
/// The last status, or `None` once the daemon has gone.
|
||||
status: Option<Response>,
|
||||
items: Vec<TransferItem>,
|
||||
jobs: Vec<JobItem>,
|
||||
}
|
||||
|
||||
impl Heard {
|
||||
fn take(&mut self, feed: Feed) {
|
||||
match feed {
|
||||
Feed::Event(Event::Status(status)) => self.status = Some(*status),
|
||||
Feed::Event(Event::Transfers { items, jobs }) => {
|
||||
self.items = items;
|
||||
self.jobs = jobs;
|
||||
}
|
||||
Feed::Disconnected => *self = Self::default(),
|
||||
Feed::Connected | Feed::Event(Event::Changed { .. } | Event::Heartbeat) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! The bundled resources and the optional Proton theme, shared by `pdfs-app`
|
||||
//! and `pdfs-prompt` so both paint the same way from the same preference.
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use adw::prelude::*;
|
||||
use gtk4::{gio, glib};
|
||||
|
||||
/// Register the bundled GResources (custom icons, the stylesheets) and load
|
||||
/// `stylesheet`, the front end's own sheet. The Proton theme is a separate
|
||||
/// provider, applied by [`set_proton_theme`] from the saved preference.
|
||||
pub(crate) fn load_resources(stylesheet: &str) {
|
||||
let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/pdfs.gresource"));
|
||||
let resource_data = glib::Bytes::from_static(bytes);
|
||||
if let Ok(resource) = gio::Resource::from_data(&resource_data) {
|
||||
gio::resources_register(&resource);
|
||||
} else {
|
||||
tracing::error!("failed to load gresource bundle");
|
||||
}
|
||||
|
||||
let Some(display) = gtk4::gdk::Display::default() else {
|
||||
return;
|
||||
};
|
||||
let provider = gtk4::CssProvider::new();
|
||||
provider.load_from_resource(stylesheet);
|
||||
// Above the user's own gtk.css, which often imports a whole theme: its
|
||||
// `button { padding: 6px 10px; min-width: 16px }` would otherwise pad every
|
||||
// photo tile and push the grid past the window. Every rule in the sheet is
|
||||
// scoped to the app's own classes, so nothing else the user styled changes.
|
||||
// One above the Proton theme too, so the app's rules refine it.
|
||||
gtk4::style_context_add_provider_for_display(
|
||||
&display,
|
||||
&provider,
|
||||
gtk4::STYLE_PROVIDER_PRIORITY_USER + 2,
|
||||
);
|
||||
gtk4::IconTheme::for_display(&display).add_resource_path("/de/nils/protondrivelinux/icons");
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The Proton theme provider while it is installed. Kept so turning the
|
||||
/// preference off can remove exactly what turning it on added.
|
||||
static PROTON_THEME: RefCell<Option<gtk4::CssProvider>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Whether the Proton theme is on.
|
||||
pub(crate) fn proton_theme_active() -> bool {
|
||||
PROTON_THEME.with(|slot| slot.borrow().is_some())
|
||||
}
|
||||
|
||||
/// libadwaita's own stylesheet, bundled in the library.
|
||||
const ADWAITA_STYLESHEET: &str = "/org/gnome/Adwaita/styles/gtk.css";
|
||||
|
||||
/// The Proton palette laid over it, from our own bundle.
|
||||
const PROTON_PALETTE: &str = "/de/nils/protondrivelinux/proton-theme.css";
|
||||
|
||||
/// Make the theme provider's `@media (prefers-color-scheme)` and
|
||||
/// `(prefers-contrast)` rules match what libadwaita shows, now and whenever it
|
||||
/// changes. A provider evaluates those itself, and a new one assumes light.
|
||||
///
|
||||
/// The properties are set by name because they only exist since GTK 4.20, and
|
||||
/// the app still runs on older GTK, whose libadwaita has no such rules anyway.
|
||||
fn follow_style_manager(provider: >k4::CssProvider) {
|
||||
fn set_enum(provider: >k4::CssProvider, property: &str, nick: &str) {
|
||||
let Some(pspec) = provider.find_property(property) else {
|
||||
return;
|
||||
};
|
||||
if let Some(value) = glib::EnumClass::with_type(pspec.value_type())
|
||||
.and_then(|class| class.to_value_by_nick(nick))
|
||||
{
|
||||
provider.set_property_from_value(property, &value);
|
||||
}
|
||||
}
|
||||
fn apply(provider: >k4::CssProvider, manager: &adw::StyleManager) {
|
||||
let scheme = if manager.is_dark() { "dark" } else { "light" };
|
||||
set_enum(provider, "prefers-color-scheme", scheme);
|
||||
let contrast = if manager.is_high_contrast() {
|
||||
"more"
|
||||
} else {
|
||||
"no-preference"
|
||||
};
|
||||
set_enum(provider, "prefers-contrast", contrast);
|
||||
}
|
||||
|
||||
let manager = adw::StyleManager::default();
|
||||
apply(provider, &manager);
|
||||
// Weak, so a provider the preference removed isn't kept alive; its
|
||||
// handlers then do nothing.
|
||||
let weak = provider.downgrade();
|
||||
let weak_dark = weak.clone();
|
||||
manager.connect_dark_notify(move |manager| {
|
||||
if let Some(provider) = weak_dark.upgrade() {
|
||||
apply(&provider, manager);
|
||||
}
|
||||
});
|
||||
manager.connect_high_contrast_notify(move |manager| {
|
||||
if let Some(provider) = weak.upgrade() {
|
||||
apply(&provider, manager);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Paint the window in the Proton web apps' colours (`on`) or follow the
|
||||
/// system theme and accent colour.
|
||||
///
|
||||
/// Setting libadwaita's colour variables alone isn't enough: a GTK theme the
|
||||
/// user's `gtk.css` imports (Catppuccin, Fluent, ...) sits above the app and
|
||||
/// hard-codes its own accent into hundreds of rules. So the theme provider,
|
||||
/// one above the user's CSS, first resets every property, then re-applies
|
||||
/// libadwaita's whole stylesheet and finally the Proton palette, which leaves
|
||||
/// nothing of the user's theme inside the app.
|
||||
pub(crate) fn set_proton_theme(on: bool) {
|
||||
let Some(display) = gtk4::gdk::Display::default() else {
|
||||
return;
|
||||
};
|
||||
PROTON_THEME.with(|slot| {
|
||||
let mut slot = slot.borrow_mut();
|
||||
match (on, slot.as_ref()) {
|
||||
(true, None) => {
|
||||
let stylesheet = |path| {
|
||||
gio::resources_lookup_data(path, gio::ResourceLookupFlags::NONE)
|
||||
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
|
||||
};
|
||||
let palette = stylesheet(PROTON_PALETTE).unwrap_or_default();
|
||||
let css = match stylesheet(ADWAITA_STYLESHEET) {
|
||||
// Its images are named relative to the sheet, which a
|
||||
// string loaded here doesn't have.
|
||||
Ok(adwaita) => format!(
|
||||
"* {{ all: unset; }}\n{}\n{palette}",
|
||||
adwaita.replace(
|
||||
"url(\"assets/",
|
||||
"url(\"resource:///org/gnome/Adwaita/styles/assets/"
|
||||
)
|
||||
),
|
||||
// Still recolours everything that follows libadwaita's
|
||||
// variables; only a custom theme's own rules show through.
|
||||
Err(e) => {
|
||||
tracing::warn!("libadwaita stylesheet not found, only recolouring: {e}");
|
||||
palette
|
||||
}
|
||||
};
|
||||
let provider = gtk4::CssProvider::new();
|
||||
follow_style_manager(&provider);
|
||||
provider.load_from_string(&css);
|
||||
gtk4::style_context_add_provider_for_display(
|
||||
&display,
|
||||
&provider,
|
||||
gtk4::STYLE_PROVIDER_PRIORITY_USER + 1,
|
||||
);
|
||||
*slot = Some(provider);
|
||||
}
|
||||
(false, Some(provider)) => {
|
||||
gtk4::style_context_remove_provider_for_display(&display, provider);
|
||||
*slot = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Every symbolic icon the app window and the search prompt ask for must
|
||||
//! exist on a stock GNOME desktop.
|
||||
//!
|
||||
//! An icon name that no theme ships shows up as a missing-image glyph, and
|
||||
//! nothing fails until someone looks. So each `"…-symbolic"` literal in the
|
||||
//! sources has to be either one of the `pdfs-*` icons bundled in
|
||||
//! `resources/icons` (and listed in the gresource manifest), or on
|
||||
//! [`STOCK`], a list of names checked against Adwaita 50, libadwaita and
|
||||
//! GTK's own built-in icons.
|
||||
//!
|
||||
//! The tray is left out: its icons are drawn by the panel, from the panel's
|
||||
//! theme, not from our resources.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Stock symbolic icons the app may use. Before adding a name, check that
|
||||
/// Adwaita has it outside its `legacy` folder, or that libadwaita or GTK
|
||||
/// ship it themselves.
|
||||
const STOCK: &[&str] = &[
|
||||
// libadwaita
|
||||
"adw-external-link-symbolic",
|
||||
// GTK's built-in icons
|
||||
"info-outline-symbolic",
|
||||
// Adwaita
|
||||
"checkbox-checked-symbolic",
|
||||
"checkbox-symbolic",
|
||||
"computer-symbolic",
|
||||
"dialog-error-symbolic",
|
||||
"dialog-warning-symbolic",
|
||||
"document-edit-symbolic",
|
||||
"document-open-recent-symbolic",
|
||||
"document-open-symbolic",
|
||||
"document-save-symbolic",
|
||||
"drive-harddisk-symbolic",
|
||||
"edit-copy-symbolic",
|
||||
"edit-delete-symbolic",
|
||||
"edit-find-symbolic",
|
||||
"edit-undo-symbolic",
|
||||
"folder-download-symbolic",
|
||||
"folder-new-symbolic",
|
||||
"folder-open-symbolic",
|
||||
"folder-remote-symbolic",
|
||||
"folder-symbolic",
|
||||
"go-down-symbolic",
|
||||
"go-jump-symbolic",
|
||||
"go-next-symbolic",
|
||||
"go-previous-symbolic",
|
||||
"go-up-symbolic",
|
||||
"image-missing-symbolic",
|
||||
"image-x-generic-symbolic",
|
||||
"list-add-symbolic",
|
||||
"mark-location-symbolic",
|
||||
"media-playback-pause-symbolic",
|
||||
"media-playback-start-symbolic",
|
||||
"network-idle-symbolic",
|
||||
"network-offline-symbolic",
|
||||
"non-starred-symbolic",
|
||||
"object-select-symbolic",
|
||||
"preferences-system-symbolic",
|
||||
"process-stop-symbolic",
|
||||
"starred-symbolic",
|
||||
"system-search-symbolic",
|
||||
"text-x-generic-symbolic",
|
||||
"user-trash-symbolic",
|
||||
"view-grid-symbolic",
|
||||
"view-list-symbolic",
|
||||
"view-more-symbolic",
|
||||
"view-paged-symbolic",
|
||||
"view-pin-symbolic",
|
||||
"view-refresh-symbolic",
|
||||
"window-close-symbolic",
|
||||
"zoom-fit-best-symbolic",
|
||||
"zoom-in-symbolic",
|
||||
"zoom-out-symbolic",
|
||||
];
|
||||
|
||||
fn crate_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
}
|
||||
|
||||
fn rust_files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
for entry in fs::read_dir(dir).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.is_dir() {
|
||||
rust_files(&path, out);
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every `"name-symbolic"` string literal in `source`. Found by suffix rather
|
||||
/// than by pairing quotes, so an escaped quote or a `'"'` elsewhere in the
|
||||
/// file can't hide one.
|
||||
fn symbolic_literals(source: &str) -> Vec<String> {
|
||||
const SUFFIX: &str = "-symbolic\"";
|
||||
let is_name = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-';
|
||||
source
|
||||
.match_indices(SUFFIX)
|
||||
.filter_map(|(at, _)| {
|
||||
let head = &source[..at];
|
||||
let start = head.trim_end_matches(is_name).len();
|
||||
(start > 0 && head[..start].ends_with('"'))
|
||||
.then(|| format!("{}-symbolic", &head[start..]))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The `pdfs-*` icons in `resources/icons`, by name.
|
||||
fn bundled() -> BTreeSet<String> {
|
||||
let mut names = BTreeSet::new();
|
||||
let root = crate_dir().join("resources/icons/scalable");
|
||||
for context in fs::read_dir(root).unwrap() {
|
||||
for icon in fs::read_dir(context.unwrap().path()).unwrap() {
|
||||
let path = icon.unwrap().path();
|
||||
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
names.insert(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_symbolic_icon_exists_on_a_stock_desktop() {
|
||||
let mut files = vec![crate_dir().join("src/prompt.rs")];
|
||||
rust_files(&crate_dir().join("src/app"), &mut files);
|
||||
let bundled = bundled();
|
||||
let mut unknown = BTreeSet::new();
|
||||
for file in files {
|
||||
let source = fs::read_to_string(&file).unwrap();
|
||||
for name in symbolic_literals(&source) {
|
||||
if !bundled.contains(&name) && !STOCK.contains(&name.as_str()) {
|
||||
let file = file
|
||||
.strip_prefix(crate_dir())
|
||||
.unwrap()
|
||||
.display()
|
||||
.to_string();
|
||||
unknown.insert(format!("{name} ({file})"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
unknown.is_empty(),
|
||||
"icons that are neither bundled nor on the stock list:\n {}",
|
||||
unknown.into_iter().collect::<Vec<_>>().join("\n ")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_bundled_icon_is_in_the_resource_manifest() {
|
||||
let manifest = fs::read_to_string(crate_dir().join("resources/pdfs.gresource.xml")).unwrap();
|
||||
let missing: Vec<String> = bundled()
|
||||
.into_iter()
|
||||
.filter(|name| name.starts_with("pdfs-") && !manifest.contains(&format!("/{name}.svg<")))
|
||||
.collect();
|
||||
assert!(missing.is_empty(), "not in pdfs.gresource.xml: {missing:?}");
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
# Proton Drive Linux Client: Architecture Specification
|
||||
# Architecture
|
||||
|
||||
This document provides a deep, comprehensive architectural description of the Proton Drive Linux Client (`pdfs`). It specifies the design patterns, data flows, thread models, and subsystem dependencies that govern the virtual filesystem (FUSE), database persistence, cache management, and two-way sync engine.
|
||||
This document describes how the Proton Drive Linux client (`pdfs`) is built: the crates, the
|
||||
filesystem and its caches, the write queue, the sync engine, the control protocol, the thread
|
||||
layout, what the client writes to disk in plaintext, and the CAPTCHA flow. It is written for
|
||||
contributors and for anyone auditing the client. User-facing documentation starts at
|
||||
[docs/README.md](README.md).
|
||||
|
||||
Numbers quoted here (thread counts, timeouts, limits) are the constants in the source at the time
|
||||
of writing; the source is authoritative when they disagree.
|
||||
|
||||
---
|
||||
|
||||
@@ -35,6 +42,11 @@ graph TD
|
||||
| [`pdfs-cli`](../crates/pdfs-cli) | Command Line Interface | Command routing, daemon launcher, IPC client wrapper. | Stateless; communicates with daemon over IPC control socket. |
|
||||
| [`pdfs-gui`](../crates/pdfs-gui) | Graphical Interface | GTK pages, tray, and the resident quick-search prompt. | Keeps UI state only; all durable state and Drive access remain behind the IPC socket. |
|
||||
|
||||
The workspace ships four binaries: `pdfs` (the CLI, and the daemon as `pdfs daemon`) from
|
||||
`pdfs-cli`, and `pdfs-app`, `pdfs-tray` and `pdfs-prompt` from `pdfs-gui`. The daemon runs as the
|
||||
systemd user unit `proton-drive.service`. Only the daemon holds the database, the caches and the
|
||||
API client; every other binary is a client of its control socket.
|
||||
|
||||
---
|
||||
|
||||
## 2. In-Memory VFS State & File Operations
|
||||
@@ -105,11 +117,29 @@ A mirror folder is a plain local directory with no FUSE session, so a row can le
|
||||
|
||||
## 3. Read Path & Block Caching Pipeline
|
||||
|
||||
Read requests are parallelized and served in blocks of size `BLOCK_SIZE` (4 MiB).
|
||||
Reads are served from the revision's content blocks. `Core::read_range` looks in three places
|
||||
before the network:
|
||||
|
||||
* **Unpinned files (Streaming):** Avoids writing full files to disk to preserve storage. Instead, blocks are kept in a fixed-size `stream_ring` (in-memory ring cache) and evicted immediately.
|
||||
* **Pinned files (Persistent):** Block downloads are saved directly to `ContentCache` on disk.
|
||||
* **Read-Ahead:** The reader thread spawns asynchronous tasks to pre-fetch upcoming blocks.
|
||||
1. **Whole-file blob.** A file kept available offline is downloaded completely into
|
||||
`ContentCache` and read from there.
|
||||
2. **Stream ring.** For every other file, a bounded in-memory ring (`RING_BYTES`, 128 MiB) of
|
||||
recently decrypted blocks is checked first, because the kernel reads in pieces far smaller
|
||||
than a block.
|
||||
3. **Block cache.** Blocks are then looked up on disk, under `content/blocks/`. They count
|
||||
against `cache_budget` and are evicted least-recently-used.
|
||||
|
||||
On a miss, only the blocks that overlap the request are fetched, through a `RevisionReader` kept
|
||||
open per revision (`MAX_OPEN_READERS = 64`, validated by `(mtime, size)`).
|
||||
|
||||
* **Block geometry.** Block boundaries come from the revision itself. The first read of a file
|
||||
assumes uniform `BLOCK_SIZE` (4 MiB) blocks; once a reader is open, the real block sizes are
|
||||
recorded (`store_block_geometry`) and later reads plan on them. Proton does not guarantee
|
||||
4 MiB blocks, and assuming it served wrong bytes (`docs/BUGS.md` B85).
|
||||
* **Read-ahead.** A read that proves sequential prefetches a window of 2 to 8 blocks
|
||||
(`PREFETCH_MIN`, `PREFETCH_MAX`). Prefetch takes a permit from a global budget of 8 and gives up
|
||||
instead of queueing, so it never delays a demand read.
|
||||
* **Large videos.** An unpinned video of 256 MiB or more (`STREAM_BYPASS_MIN`) streams without
|
||||
persisting its blocks, so watching a film does not evict the rest of the cache.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -121,21 +151,25 @@ sequenceDiagram
|
||||
participant API as Proton API Client
|
||||
|
||||
Kernel->>FUSE: read(ino, fh, offset, size)
|
||||
FUSE->>FUSE: calculate block indices [first_block..last_block]
|
||||
loop For each block index
|
||||
alt Block in disk Cache (Pinned/Cache hit)
|
||||
FUSE->>Cache: read_block(block_idx)
|
||||
Cache-->>FUSE: block bytes
|
||||
else Block in stream ring cache
|
||||
FUSE->>Ring: get(block_idx)
|
||||
Ring-->>FUSE: block bytes
|
||||
else Block Cache Miss
|
||||
FUSE->>API: download_range(offset, len)
|
||||
API-->>FUSE: decrypted block bytes
|
||||
alt caching_enabled
|
||||
FUSE->>Cache: store_block(block_idx)
|
||||
else streaming_only
|
||||
FUSE->>Ring: insert(block_idx)
|
||||
alt Whole-file blob cached (available offline)
|
||||
FUSE->>Cache: read blob range
|
||||
Cache-->>FUSE: bytes
|
||||
else
|
||||
FUSE->>FUSE: block_geometry() spans overlapping [offset, offset+size)
|
||||
loop For each span
|
||||
alt Span in stream ring
|
||||
FUSE->>Ring: get(span)
|
||||
Ring-->>FUSE: block bytes
|
||||
else Span in block cache
|
||||
FUSE->>Cache: read_block(span)
|
||||
Cache-->>FUSE: block bytes
|
||||
else Miss
|
||||
FUSE->>API: RevisionReader.read_at(span)
|
||||
API-->>FUSE: decrypted block bytes
|
||||
opt Not a large unpinned video
|
||||
FUSE->>Cache: store_block(span)
|
||||
end
|
||||
FUSE->>Ring: insert(span)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -220,6 +254,35 @@ graph TD
|
||||
5. **Depth-Ascending Batching:** Folders are processed first to ensure hierarchies exist before files are placed. Work is executed concurrently up to a set limit.
|
||||
6. **Safety Gate and Settle:** Destructive plans are rejected when the scan is incomplete. Total-wipe protection applies to every non-empty baseline, including a single-entry folder. On success, baseline entries are upserted, timestamps updated, and any pending mode switches (e.g. going on-demand) are evaluated. Failed local deletion or conflict preservation retains the previous baseline and prevents remote content from overwriting the local source.
|
||||
|
||||
### Conflict Copies
|
||||
|
||||
A conflict keeps both versions. The local file is renamed to
|
||||
`{stem} (sync-conflict {unix_secs}).{ext}` (with `-1`, `-2`, … appended to the timestamp when the
|
||||
name is taken) and the remote version is downloaded under the original name. The conflict sweep
|
||||
(§7) later trashes copies proven byte-identical to their sibling when `conflict_sweep` is
|
||||
`enforce`.
|
||||
|
||||
### Devices and the Machine Profile
|
||||
|
||||
Synced folders live under a *device*: a per-computer root in Proton Drive. `Core::ensure_device`
|
||||
decides which device this machine is: the adopted `device_uid` from `config.json` when set,
|
||||
otherwise a Linux device whose name equals the hostname, otherwise a newly registered one. An
|
||||
adopted UID that no longer exists is reported, never silently replaced.
|
||||
|
||||
Because the local database does not survive a lost machine, the daemon backs up the machine's
|
||||
arrangement to `<device root>/.proton-drive-linux/profile.json` (`pdfs_core::profile`): each synced
|
||||
folder's remote UID, local path and mode, the pins, and the ignore patterns. It goes up the normal
|
||||
upload path and so is end-to-end encrypted like any other file. Mutating requests call
|
||||
`Core::touch_profile`, which coalesces a burst of changes into one upload. `PROFILE_VERSION`
|
||||
guards the format: a reader refuses a profile from a newer version instead of applying part of it.
|
||||
|
||||
Restore (`ListRestorableFolders`, `RestoreSyncFolders`, and the `Device` variants for another
|
||||
computer) lists the device root's folders, proposes a local path from the profile when its parent
|
||||
exists on this machine and `~/<name>` otherwise, and on confirmation adds a `sync_folder` row per
|
||||
folder bound to the remote UID. The download is an ordinary reconcile against an empty baseline;
|
||||
on-demand mode is applied through the normal mode-switch request. The user guide is
|
||||
[RECOVERY.md](RECOVERY.md).
|
||||
|
||||
---
|
||||
|
||||
## 6. IPC Socket Protocol
|
||||
@@ -232,7 +295,31 @@ The CLI and GUI front-ends do not access database files or make network calls di
|
||||
* **Control Protocol:**
|
||||
* Client sends a single JSON line (`Request`).
|
||||
* Daemon parses, handles the request, and replies with a single JSON line (`Response`).
|
||||
* Timeout durations are separated: **2 seconds** for writes (avoids hangs on defunct sockets) and **120 seconds** for reads (accommodates heavy transfers).
|
||||
* Timeout durations are separated: **2 seconds** for writes (avoids hangs on defunct sockets) and **120 seconds** for reads (accommodates heavy transfers). Status polls use a 5-second read timeout, which is what the app reports as "Not responding".
|
||||
* **Errors:** A failed request replies with `Response::Error { message, kind }`. The `kind` is machine-readable, and `pdfs --json` passes it through.
|
||||
* **Compatibility:** After an upgrade, the old daemon keeps running until the service restarts, so front ends must cope with a daemon that does not know a newer request. It answers such a request with an error instead of dropping the connection. New fields are added with `#[serde(default)]`, and new variants get a separate request rather than an optional field an old daemon would ignore.
|
||||
|
||||
### Push Events
|
||||
|
||||
Since 2.5.0 the front ends do not poll. The app and the tray each send `Request::Subscribe` once
|
||||
and keep the connection open; the daemon then streams one `Event` per line:
|
||||
|
||||
| Event | Sent when | Payload |
|
||||
|---|---|---|
|
||||
| `Status` | On subscribe, and whenever any status field changes | A full `Response::Status` |
|
||||
| `Transfers` | On subscribe, and whenever the transfer snapshot changes | Transfers and background jobs |
|
||||
| `Changed { topics }` | Something a page shows changed | `Topic`s to re-read: `files`, `photos`, `trash`, `shares`, `devices`, `locations`, `queue`, `conflicts`, `activity` |
|
||||
| `Heartbeat` | Every 10 seconds of silence | — |
|
||||
|
||||
Code that changes state publishes its topics to the `EventHub` (`crates/pdfs-fuse/src/events.rs`);
|
||||
the control handler maps each mutating request to the topics it touches. Changes within 150 ms
|
||||
of each other are coalesced into one event. A new subscription is told every topic changed, so it
|
||||
catches up on anything it missed. At most 8 subscriptions are served at once.
|
||||
|
||||
On the client side, `pdfs_core::control::follow` reconnects across daemon restarts with backoff
|
||||
and treats 25 seconds without a line as a stuck daemon. A daemon older than 2.5.0 answers
|
||||
`Subscribe` with an error; `follow` then falls back to polling status and transfers, so the front
|
||||
end sees the same events, only later.
|
||||
|
||||
### Unified Search
|
||||
|
||||
@@ -246,12 +333,18 @@ The prompt is a single-instance GTK application. It retains and hides its window
|
||||
|
||||
The background daemon relies on the following thread topology:
|
||||
|
||||
1. **Main Thread / Dispatch Loop:** Blocks on `fuser::Session` loop. Reads kernel FUSE events and hands off network-bound VFS work to the FUSE workers pool.
|
||||
1. **Main Thread / Dispatch Loop:** Blocks on `fuser::Session` loop. Reads kernel FUSE events and hands off network-bound VFS work to the FUSE workers pool. Each on-demand synced folder is a further FUSE session with its own dispatch loop and its own inode space (`State`).
|
||||
2. **FUSE Workers Pool (11 threads, two lanes):** Bounded thread pool handling network operations. Split into 3 threads reserved for metadata (`lookup`, `readdir`) and 8 general threads that serve transfers (block reads) and fall back to metadata when no transfer is waiting. Reserved threads never accept a transfer — that is what keeps a directory listing from queuing behind saturated downloads (audit A6).
|
||||
3. **IPC listener Thread:** Listens on the Unix socket and admits at most 64 concurrent, timeout-bound handlers for front-end status/configuration requests.
|
||||
4. **Sync Engine Loop Thread:** Serializes sync runs. Wakes on debounced local inotify filesystem changes, remote polling intervals, or manual user requests.
|
||||
5. **Drain Queue Worker Thread:** Processes staged writes (`PendingOp`) sequentially, uploading revisions and retrying with exponential backoff on failures.
|
||||
3. **Control Threads (`pdfs-control`):** Listen on the Unix socket and admit at most 64 concurrent, timeout-bound handlers. Long-lived subscriptions hold one handler each, up to 8 (see §6).
|
||||
4. **Sync Engine Threads (`pdfs-sync`, `pdfs-sync-poll`):** Serialize sync passes. Wake on debounced inotify changes, the periodic poll, remote events, or a user request.
|
||||
5. **Drain Workers (`pdfs-drain-0` … `pdfs-drain-2`):** `DRAIN_WORKERS = 3` threads share the queue of staged writes and changes (`PendingOp`), with per-node ordering (§4), and retry failures with exponential backoff. Worker 0 also runs the queue's idle chores.
|
||||
6. **Conflict Sweep Thread (`pdfs-conflict-sweep`, optional):** Reconciles leftover `(sync-conflict …)` copies — 30 s warmup, then one pass every 5 minutes. A copy proven identical to its live sibling (equal size **and** equal `content_sha1`) is redundant; anything it cannot prove identical is surfaced to the activity feed and left alone. Governed by `AppConfig.conflict_sweep` / `PDFS_CONFLICT_SWEEP` (`SweepMode`): **report-only by default**, `off` skips the thread entirely, and only `enforce` lets it trash. Because it deletes, the enforcing path re-verifies revision id, size, digest, name, parent, queued ops and open handles immediately before acting rather than trusting its own pass-start snapshot. See `docs/BUGS.md` B69 and B71.
|
||||
7. **Remote Event Tasks (Tokio):** `run_event_sync` follows Proton's event stream for the Drive volume and applies each change to every mounted inode space, invalidating kernel entries as it goes. The cursor is persisted after every batch, so changes made while the daemon was stopped are applied on the next start. `run_photos_event_sync` follows the photos volume separately; it only drops gallery rows or the timeline's freshness stamp, since photos are not part of the mount.
|
||||
8. **Event Sampler (`pdfs-events`):** Publishes status, transfer and sync-progress changes to subscribed front-ends (§6).
|
||||
9. **Supervisor (`pdfs-supervisor`):** Logs workers that hold one job for minutes, a queue that stops draining and control requests that never return; samples memory; and pings the systemd watchdog only after a real round trip over the control socket, so a hung daemon is restarted (`WatchdogSec=120`). `pdfs diagnostics` reports the same data on demand.
|
||||
10. **Smaller helpers:** `pdfs-online-probe` (connectivity), `pdfs-localindex` (home-directory index for the search prompt), `pdfs-pause` (ends a timed pause), `pdfs-conflict-sweep`, and `pdfs-similar` (duplicate-photo detection, on request).
|
||||
|
||||
Every long-lived loop waits on one shared `Shutdown` signal instead of sleeping, so an in-process remount joins the old generation of threads instead of leaking it (`docs/BUGS.md` B44).
|
||||
|
||||
---
|
||||
|
||||
@@ -377,3 +470,113 @@ sequenceDiagram
|
||||
This ensures compatibility with all versions of Proton's client verification scripts.
|
||||
3. **SRP Handshake Reset:** Because a gated login burns the SRP handshake on the API side, the client cannot simply resume the previous request. Instead, `auth::login_interactive` restarts the SRP process from scratch with the verification credentials attached, keeping the complex handshake details isolated from the front-end.
|
||||
4. **CLI Fallback:** Since the CLI has no native web browser engine, hitting the CAPTCHA gate fails immediately with a user-friendly message directing the user to sign in once via the GUI (`pdfs-app`) to persist the authenticated session keys to the system keyring.
|
||||
|
||||
---
|
||||
|
||||
## 10. Feature Design Notes
|
||||
|
||||
Notes on individual features whose code is shaped by a constraint that is not obvious from the
|
||||
code alone.
|
||||
|
||||
### File Version History
|
||||
**Files**: [`revisions.rs`](../crates/pdfs-fuse/src/revisions.rs), [`control.rs`](../crates/pdfs-core/src/control.rs), [`versions_dialog.rs`](../crates/pdfs-gui/src/app/widgets/versions_dialog.rs)
|
||||
|
||||
Proton Drive keeps every revision a client committed; the daemon only ever addressed the active one, so a file overwritten by a sync pass could be recovered only from whatever the local `recovery/` directory happened to hold.
|
||||
|
||||
The control protocol gained `ListRevisions` / `RestoreRevision` / `DeleteRevision` / `SaveRevisionAs` (each with a `…ByUid` twin for nodes the primary mount cannot name), the CLI gained `pdfs versions list|restore|save|rm`, and the browser's details pane gained a **Versions** button opening a per-file dialog.
|
||||
|
||||
Three properties that shape the code:
|
||||
|
||||
- **A restore is server-side and asynchronous.** No content crosses the wire and nothing enters the drain queue; the server answers 202 and swaps the active revision in the background, so the daemon evicts the file's cached blocks and open readers rather than describing the new state, and the UI wording promises the request, not the result.
|
||||
- **The active revision cannot be deleted.** `Core::delete_revision_for_uid` checks that locally so the user gets a sentence rather than an API code, and the dialog gives the current row no delete button.
|
||||
- **Saving a version never overwrites.** `SaveRevisionAs` refuses an existing destination and removes a partial file if the download fails — a half-written export looks identical to a good one to every tool that opens it afterwards.
|
||||
|
||||
### Photo Favorites
|
||||
**Files**: [`photos.rs`](../crates/pdfs-fuse/src/photos.rs), [`photo_viewer.rs`](../crates/pdfs-gui/src/app/pages/photo_viewer.rs)
|
||||
|
||||
The gallery can mark and filter favorites, through the SDK's photo-tag API (`update_photos`). Schema **v20** adds `photos.favorite`; the flag is learned in the timeline enrichment pass that already resolves each photo's name and media type, and is kept across a refresh that could not resolve a photo — the same learned-and-kept rule as `media_type`. The lightbox carries a star toggle, the Photos header a favorites filter, and the CLI has `pdfs favorite <uid> [--remove]` plus `pdfs photos --favorites`.
|
||||
|
||||
Favouriting a photo that is not on this account's own photos volume (shared with us, or album-only) needs it re-encrypted for our timeline root, which the SDK does not implement; the daemon surfaces that as an error rather than silently doing nothing.
|
||||
|
||||
### Justified Gallery, Gallery Delete
|
||||
**Files**: [`photos.rs`](../crates/pdfs-gui/src/app/pages/photos.rs), [`photo_viewer.rs`](../crates/pdfs-gui/src/app/pages/photo_viewer.rs), [`photos.rs`](../crates/pdfs-fuse/src/photos.rs), [`background.rs`](../crates/pdfs-fuse/src/background.rs)
|
||||
|
||||
The gallery lays each day out in justified rows (`justify_rows` / `plan_rows` / `fit_row`): photos are taken in capture order until their summed aspect ratio no longer fits the target row height, and the row is then scaled so it ends exactly on the content width. A tile is therefore its own photo's shape, and `ContentFit::Contain` has nothing left to crop. The last row of a day is left at the target height rather than stretched, because a day holding two photos is a short day, not a layout fault.
|
||||
|
||||
- **Ratios that are not known yet.** `PhotoItem::ratio` is persisted, but a photo that has never been decoded has none. Those are laid out square and remembered in `assumed_ratios`; when a decode proves the real shape the day re-flows on the existing debounce timer, so a screenful of decodes costs one re-flow rather than one per photo. Ratios are clamped to 0.4–3.0 so a single panorama cannot decide what a row looks like.
|
||||
- **Thumbnails survive recycling.** A reply for a tile that has already been re-bound still lands in the texture cache, which is an LRU of 1500 textures keyed by uid, and the rows just outside the realised range are prefetched in the scroll direction.
|
||||
- **Delete is uid-addressed.** The photos volume is not in the FUSE mount, so the path-based `Request::Delete` cannot reach it. `Request::TrashNodes { uids }` trashes through the SDK and answers `Response::Trashed { trashed, failed }`; the daemon then drops those rows with `Db::photos_delete` (album membership included) so the gallery does not wait for a timeline refresh. The GUI removes the tiles optimistically with an Undo toast and puts back anything the server refused.
|
||||
- **Remote deletions arrive on their own.** The Drive volume and the photos volume have separate event streams, so `run_photos_event_sync` polls the photos volume with its own cursor (`photos_event_cursor` in the state table). Trash and delete events remove rows; anything else only invalidates freshness, because there is no inode space to converge on this volume. Together with a 60 s `TIMELINE_TTL` and a `RefreshScope::Photos` that now awaits the refresh, a photo deleted on a phone leaves the grid in about ten seconds.
|
||||
|
||||
### RAW + JPEG Grouping
|
||||
**Files**: [`photos.rs`](../crates/pdfs-core/src/db/photos.rs), [`migrations.rs`](../crates/pdfs-core/src/db/migrations.rs), [`photos.rs`](../crates/pdfs-fuse/src/photos.rs), [`photo_viewer.rs`](../crates/pdfs-gui/src/app/pages/photo_viewer.rs)
|
||||
|
||||
Schema **v30** adds `content_hash`, `main_uid` and an indexed `group_key` to `photos`. The first two come from the server's `PhotoProperties` and are filled by the enrichment pass in `refresh_timeline` that already resolves each photo's name, media type and favorite tag; `group_key` is computed in `photos_replace`, where the whole timeline is in hand.
|
||||
|
||||
- **Precedence.** The server relation first (`main_photo_uid` and `related_photo_uids`, read from whichever end resolves — the two can land in different enrichment chunks); then same capture day, same case-insensitive name stem, and one member raw while the other is not; otherwise the photo is its own group. The union-find pass is over the timeline in memory, so it costs one pass per refresh rather than a query per photo.
|
||||
- **Representative.** The non-raw member when there is one — a JPEG decodes in milliseconds and is what the person expects to see. `group_key` holds that member's uid, so "is this the tile" is `uid = group_key`, an index scan.
|
||||
- **What is grouped and what is not.** `photos_page`, `photos_months` and `photos_counts` count groups, except on the Raw tab, which lists files. Album pages are never grouped: an album is a list someone made. The relation is learned-and-kept like `media_type`, so a refresh that could not resolve a node does not break a group up.
|
||||
- **Deleting.** `Core::trash_photos` expands each uid to its group before trashing, because the grid shows the shot as one tile — leaving the RAW behind would put the photo back on the Raw tab and nowhere else. The GUI's confirmation names the file count.
|
||||
- **The other files.** `Request::PhotoGroup { uid }` answers with the group's members as ordinary `PhotoItem`s, which is what the lightbox's switch steps through.
|
||||
|
||||
### Persistent SDK Entity Cache
|
||||
**Files**: [`sdkcache.rs`](../crates/pdfs-core/src/sdkcache.rs), [`auth.rs`](../crates/pdfs-core/src/auth.rs), [`background.rs`](../crates/pdfs-fuse/src/background.rs)
|
||||
|
||||
The SDK's Drive entity cache (decrypted node metadata: name, size, parent, signing share) defaulted to memory, so every daemon restart re-fetched and re-decrypted the tree the previous run had already walked. It is now backed by SQLite.
|
||||
|
||||
- **Its own file** (`sdk_cache.db` in the state directory), not `cache.db`: the daemon's `Db` is one `Mutex<Connection>` shared by every FUSE thread and the control socket, and this traffic is frequent, small and entirely reconstructible. A separate file also means no schema migration — the store can be deleted at any time.
|
||||
- **Encrypted at rest** by wrapping it in the SDK's `EncryptedCacheRepository`, keyed by the mailbox password. A password change reads as a cold cache (the SDK treats an undecryptable entry as a miss and clears the store), not as an error.
|
||||
- **Staleness** is closed by the event loop, which already replays from a persisted cursor and calls `invalidate_caches_for_event` for every event — including those raised while the daemon was down. The one case with no trail is a *seeded* cursor (first-ever mount, or a lost cursor), where the seed path now clears the store instead of trusting it. `auth::logout` removes the file.
|
||||
|
||||
Needed a small additive SDK change (`ProtonDriveClient::with_entity_repository`, 0.5.1): `with_entity_cache` is a constructor and so could not be combined with `with_key_salts`, which the daemon requires.
|
||||
|
||||
### Per-node Batch Outcomes
|
||||
**File**: [`batch.rs`](../crates/pdfs-core/src/batch.rs)
|
||||
|
||||
SDK 0.5.0 made `trash_nodes` / `restore_nodes` / `delete_nodes` report one outcome per node instead of failing the whole call (mirroring upstream's streamed `NodeActionResult`). `pdfs_core::batch::into_unit` collapses the single-node calls back to a `Result`; `batch::split` handles a collected batch.
|
||||
|
||||
A restore is expanded before it is sent (`expand_restore`, `pdfs-fuse/src/lib.rs`): the persisted trash listing carries each row's `parent_uid` (schema 29), so a restore takes the connected piece of the trashed tree — every trashed descendant of what was asked for, and every trashed ancestor above it — and sends it shallowest wave first, because the server cannot put a node back under a parent that is still trashed. A uid the listing does not know about (it is materialised in chunks, and can be stale) is restored on its own rather than dropped.
|
||||
|
||||
The trash view's restore and permanent-delete use the SDK's **streaming** variants (`restore_nodes_streaming` / `delete_nodes_streaming`) and apply local state per node as each batch lands rather than after the last one. For a permanent delete — which is irreversible — that means a daemon interrupted mid-batch has forgotten exactly the nodes the server destroyed, no more and no fewer. Both report per-node failures and count only what actually succeeded; only a batch where nothing succeeded is an error.
|
||||
|
||||
### Moves Between Locations
|
||||
**Files**: [`relocate.rs`](../crates/pdfs-fuse/src/relocate.rs), [`control.rs`](../crates/pdfs-fuse/src/control.rs), [`browser.rs`](../crates/pdfs-gui/src/app/pages/browser.rs)
|
||||
|
||||
The primary mount and every on-demand folder are separate FUSE sessions, and a mirror folder has no session at all. The kernel answers `rename(2)` between two mounts with `EXDEV` before the daemon hears of it, so `mv` copies and deletes. On Proton Drive the same move is one `move_node`, because My files and this device's folders are on the main volume.
|
||||
|
||||
`Request::Move` with two absolute paths is therefore resolved by `Core::move_between` against every location: a live mount through `rooted_at`, otherwise the mirror folder with the longest `local_path` prefix, whose uids come from the `sync_entry` baseline. Two paths in one inode space still go to `move_to`. Relative paths keep their old meaning.
|
||||
|
||||
- **Nothing that exists only on this disk may be lost.** A mounted source is refused while it or anything under it has a queued op or a file open for writing. A mirror source is refused unless every file under it matches its baseline and every baseline row still has its file (`mirror_subtree_unsynced`); ignored names and symlinks count as unsynced, because the local copy is removed after the move.
|
||||
- **Mirror to mirror renames along.** The local copy is renamed first, with `RENAME_NOREPLACE` so nothing that appeared at the destination meanwhile is overwritten, and its baseline rows are moved with `sync_entries_move`, so neither folder's next pass has anything to do. If `move_node` fails, the rename is undone. `EXDEV` between two filesystems falls back to the check above, then drops the source copy.
|
||||
- **A dropped mirror copy is checked twice.** The check runs again just before the source copy is deleted. A file written into it during the move keeps the whole copy; its baseline is gone, so the next pass uploads it as new.
|
||||
- **Sync passes are held off.** Every mirror folder involved is locked through `sync_lock`, by ascending id, with a five-second bound. A folder that is still busy is reported rather than waited on.
|
||||
- **The local side is levelled afterwards.** A mounted source forgets the node in every inode space and notifies the kernel. A mounted destination has its listing cleared. A mirror destination that received nothing locally gets a reconcile, which downloads the node. That download first asks `ContentCache::copy_cached_to` for the file: a whole cached blob, or every block of it, is copied into place instead of fetched, so a move out of an on-demand folder costs no traffic for what was already opened there.
|
||||
|
||||
### Open-for-Write Deferral for Mirror Sync
|
||||
**File**: [`sync.rs`](../crates/pdfs-fuse/src/sync.rs)
|
||||
|
||||
The mirror folder sync engine now defers uploading any file that is currently held open for writing by another process, matching the guarantee the FUSE mount path already provides (where uploads are deferred until `close(fd)`).
|
||||
|
||||
**Problem**: Previously, the mirror sync path relied solely on a 2-second trailing-edge debounce (with a 30-second ceiling) to avoid uploading files mid-write. This was insufficient for slow continuous writers (e.g., database dumps, large exports, or editors that keep files open for extended periods), which could have their incomplete state uploaded as a real revision.
|
||||
|
||||
**Solution**: Before each reconcile pass, the sync engine scans `/proc/*/fd` once to build a set of canonical paths within the sync root that any process holds open for writing (`O_WRONLY` or `O_RDWR`). Files in this set are:
|
||||
|
||||
- **Kept in the local walk** — so they are not misclassified as deletions
|
||||
- **Treated as unchanged** — so no upload is queued for them
|
||||
- **Counted as `deferred`** — so the activity summary reports them (e.g., "3 uploaded, 1 deferred (open for write)")
|
||||
- **Picked up on the next pass** — after the writer has closed the file
|
||||
|
||||
**How it works**:
|
||||
1. `open_for_write_set(root)` reads `/proc/*/fd` → `readlink` for each fd → prefix-checks against the sync root → reads `/proc/<pid>/fdinfo/<n>` to check the `flags:` line's low two bits (O_ACCMODE)
|
||||
2. `walk_local` sets `LocalItem.open_for_write = true` for matching files
|
||||
3. Both `do_reconcile` and `push_pass` skip upload classification for flagged files
|
||||
4. `Outcome.deferred` tracks the count for the activity summary
|
||||
|
||||
**Performance**: The `/proc` scan completes in low single-digit milliseconds on a typical desktop. Only fds whose `readlink` target falls under the sync root incur the `fdinfo` read.
|
||||
|
||||
**Tests added**: 8 unit tests covering `is_write_mode` parsing of various fdinfo flag combinations, and `Outcome` summary formatting with deferred counts.
|
||||
|
||||
| Sync Path | Open-for-write protection | Mechanism |
|
||||
|---|---|---|
|
||||
| FUSE mount | Yes | Upload deferred until `close(fd)` |
|
||||
| Mirror folders | Yes | `/proc/*/fd` scan per reconcile pass |
|
||||
@@ -12,6 +12,334 @@ Conventions:
|
||||
|
||||
---
|
||||
|
||||
## B111 — Renaming a file back right after renaming it fails with EIO
|
||||
|
||||
**Status:** Fixed (unverified).
|
||||
**Found:** 2026-09-29, by the acceptance case "unusual but legal names", on My files and on the
|
||||
on-demand folder. The case renames each file to `renamed-N` and back at once. One rename back per
|
||||
run failed with `EIO`, and the journal showed `rename failed ... proton api error
|
||||
InvalidRequirements (http 422): This file or folder was out of date, rename failed.`
|
||||
|
||||
**Where:** every remote rename: `serve_rename` in `crates/pdfs-fuse/src/filesystem.rs`,
|
||||
`drain_rename_in_place` in `crates/pdfs-fuse/src/drain.rs`, and the control `rename` in
|
||||
`crates/pdfs-fuse/src/lib.rs`.
|
||||
|
||||
**Cause.** The SDK's `rename_node` reads the node's details from the server and sends its current
|
||||
name hash as `OriginalHash`. Right after a rename of the same node, that read can still return the
|
||||
old hash. Drive then refuses the request as out of date. The daemon reported this at once as
|
||||
`EIO`, although the same request succeeds a moment later.
|
||||
|
||||
**Fix.** All remote renames go through `Core::rename_remote`. It retries a rename that fails with
|
||||
`InvalidRequirements` up to 20 times, half a second apart, the same window the move after a
|
||||
rename already uses. Any other error still returns at once.
|
||||
|
||||
## B110 — A read that settles a provisional size deadlocks on its own page lock
|
||||
|
||||
**Status:** Fixed (unverified).
|
||||
**Found:** 2026-09-29, by the acceptance case "application workloads", which timed out on My
|
||||
files and on the on-demand folder on a fast network. The journal showed `a fuse worker has held
|
||||
the same job for a long time worker=pdfs-fuse-8 job=read` growing past 700 s with no network
|
||||
error. `/proc/<pid>/task/*/wchan` put that worker, and one more, in `__folio_lock`, while the
|
||||
daemon had no file on any mount open.
|
||||
|
||||
**Where:** `crates/pdfs-fuse/src/lib.rs` (`Core::apply_size_upgrade`).
|
||||
|
||||
**Cause.** B100 made `read` settle a provisional size before it fetches, through
|
||||
`resolve_provisional_size` and `apply_size_upgrade`. That function tells the kernel about the new
|
||||
size with `inval_inode(ino, 0, 0)`, which also drops the file's page cache. The kernel holds the
|
||||
page lock of the read being served until the daemon replies. The invalidation waits for the same
|
||||
lock, on the thread that has to send the reply, so neither ever finishes. The process that
|
||||
issued the read hangs, and so does anything else that touches those pages.
|
||||
|
||||
**Fix.** The size upgrade invalidates attributes only (`offset -1`). A size upgrade does not
|
||||
change content, so the page cache has nothing stale to drop. A daemon already stuck this way
|
||||
needs a restart.
|
||||
|
||||
## B109 — A re-listing shrinks a file that is open for writing, and SQLite reads it as malformed
|
||||
|
||||
**Status:** Fixed (unverified).
|
||||
**Found:** 2026-09-29, by the acceptance case "application workloads" on My files. It failed
|
||||
several runs in a row with `database disk image is malformed` at the `SELECT COUNT(*)` right
|
||||
after a 500-row insert, and the daemon logged nothing. A diagnosis added to the case showed
|
||||
`workload.db` at 0 bytes while SQLite still held it open with 16 KiB written.
|
||||
|
||||
**Where:** `crates/pdfs-fuse/src/state.rs` (`State::keep_open_write_size`, called from
|
||||
`intern_mem_with_access`), the local-uid remap in `crates/pdfs-fuse/src/drain.rs`, and the size
|
||||
upgrade in `crates/pdfs-fuse/src/lib.rs`.
|
||||
|
||||
**Cause.** An online create seals an empty file on Drive. Until the last close, the written
|
||||
bytes live only in the open handle, and the entry's size is the handle's. Any re-listing of the
|
||||
parent re-interned the node from Drive, which replaced that size with 0. Such re-listings come
|
||||
from event echoes and sibling creates, and SQLite's journal is one. The kernel then got size 0
|
||||
from `getattr` and ended SQLite's reads at EOF without asking the daemon, so the database read
|
||||
back as empty pages. Queued writes were already protected (B11); open handles were not.
|
||||
|
||||
**Fix.** A node that has an open write handle keeps the handle's size and its local mtime when
|
||||
a listing, a size upgrade or a create landing replaces its entry. After the last close, the
|
||||
queued write takes over as before.
|
||||
|
||||
## B108 — A file deleted during a mirror pass puts the sync folder in `error`
|
||||
|
||||
**Status:** Fixed (unverified).
|
||||
**Found:** 2026-09-29, by the account run of `scripts/fuse-acceptance.sh --quick`. It stopped with
|
||||
`sync folder … entered error` before the managed-move phase. At the same time the journal
|
||||
showed `sync: step failed; continuing error=open …/workloads/tree.tar: No such file or directory`
|
||||
for eleven files. The contract suite had removed its `workloads` tree while the mirror pass
|
||||
was still uploading it.
|
||||
|
||||
**Where:** `crates/pdfs-fuse/src/sync.rs` (`upload_new`, `upload_revision`, `open_to_upload`).
|
||||
|
||||
**Cause.** A pass plans its uploads from a scan and runs them later. When a planned file was
|
||||
deleted in between, opening it failed with `NotFound`. That counted as a failed step, so the pass
|
||||
ended in `error` and logged "item(s) failed; will retry" for files nobody wanted any more.
|
||||
|
||||
**Fix.** An upload whose local file is gone when it starts is skipped (`Applied::Vanished`) and
|
||||
does not count as an error. The next pass sees the deletion and handles it like any other.
|
||||
Other open failures are still errors.
|
||||
|
||||
## B107 — One read on a stalled connection hangs the reading program for half an hour
|
||||
|
||||
**Status:** Fixed (unverified).
|
||||
**Found:** 2026-09-29, by the acceptance case "application workloads" on My files, which hit its
|
||||
180 s timeout. The journal showed `a fuse worker has held the same job for a long time
|
||||
worker=pdfs-fuse-10 job=read` from `age_secs=124` up to `age_secs=2059`. Event polls in the
|
||||
same window failed with `HTTP transport error` after 91-103 s.
|
||||
|
||||
**Where:** `crates/pdfs-fuse/src/reads.rs` (`Core::read_range_remote`, `READ_FETCH_TIMEOUT`).
|
||||
|
||||
**Cause.** The SDK bounds each request (30 s for the API, 300 s for a storage block) and retries
|
||||
failed ones, but nothing bounds a whole read. A read can need a block table, several blocks and a
|
||||
repair, each with its own retries. On a connection that stalled without dropping, one `read`
|
||||
held its FUSE worker for about 34 minutes, and the program that issued it could not be
|
||||
interrupted for that time.
|
||||
|
||||
**Fix.** The network part of a read now has a 120 s deadline. When it passes, the read fails
|
||||
with `EIO`, the file's revision reader is dropped so the next read starts fresh, and blocks that
|
||||
already arrived stay cached for the retry. A single read is at most a few 4 MiB blocks, so this
|
||||
only fails a read on a link slower than about 70 KB/s. A block repair that has to download a
|
||||
large file whole can also hit the deadline on a slow link; pinning the file avoids that.
|
||||
|
||||
The first version of this fix built the timer on the FUSE worker thread, outside the runtime.
|
||||
Every read that went to the network panicked with `there is no reactor running`, and the kernel
|
||||
got `EIO`. The next account run failed 11 cases on My files this way. The timer is now built
|
||||
inside `block_on`, and a unit test runs that pattern from a thread without a runtime.
|
||||
|
||||
---
|
||||
|
||||
## B106 — Saving over a file that was just read fails with "already exists"
|
||||
|
||||
**Status:** Fixed (unverified).
|
||||
**Found:** 2026-09-29, by the acceptance case "application workloads" on an on-demand folder,
|
||||
after installing the B104 fix: `OSError: [Errno 5]` from `os.replace` in the editor-save loop,
|
||||
at the third save. The journal had `rename failed … AlreadyExists (http 422): A file or folder
|
||||
with name already exists` from `rename_node`.
|
||||
|
||||
**Where:** `crates/pdfs-fuse/src/lib.rs` (`Core::remove_replaced`,
|
||||
`Core::withdraw_queued_ops`), `crates/pdfs-fuse/src/filesystem.rs` (`trash_child`,
|
||||
`finish_release`), `crates/pdfs-fuse/src/drain.rs` (`Core::drain_revision`).
|
||||
|
||||
**Cause.** A regression from B104. The save loop writes a temporary file, renames it over the
|
||||
document, and reads the document back. `close()` returns before the daemon sees the release, so
|
||||
at the next save the replaced file still counts as open. B104 then left its queued upload in
|
||||
the queue until the final close, and the drain could send it after the file had been trashed.
|
||||
The next rename onto the name was refused. `unlink` of an open file had kept its queued upload
|
||||
the same way.
|
||||
|
||||
**Fix.** A file trashed or replaced while open now loses its queued ops at once, and an upload
|
||||
already under way is cancelled. Only the staged bytes stay, because the open handle reads them,
|
||||
and they are deleted at the final close. A drain worker that claimed the op before it was
|
||||
withdrawn checks that the op still exists before it sends any bytes.
|
||||
|
||||
---
|
||||
|
||||
## B105 — A file deleted while its upload is in flight comes back as a conflict copy
|
||||
|
||||
**Status:** Fixed (unverified).
|
||||
**Found:** 2026-09-29, in the daemon journal of an acceptance run on the account. During the
|
||||
SQLite workload two `queued write conflicts; keeping a conflict copy` warnings named files called
|
||||
`recovered-XrPUSW5W` and `recovered-JbZM_pWK`, with the reason `the file was trashed remotely`.
|
||||
Each was followed by `pending upload failed; will retry` and `No such file or directory`.
|
||||
|
||||
**Where:** `crates/pdfs-fuse/src/drain.rs` (`Core::drain_revision`),
|
||||
`crates/pdfs-core/src/db/ops.rs` (`Db::op_exists`).
|
||||
|
||||
**Cause.** SQLite writes, syncs and deletes its journal file within milliseconds. The sync
|
||||
queues an upload of the journal. The delete trashes the node and drops that queued upload, but a
|
||||
drain worker that already holds the op carries on. It then asks the server about the node,
|
||||
finds it trashed, and treats the trash as another device's change: it tries to upload the bytes
|
||||
as a conflict copy. The tree has already forgotten the node, so the copy is named `recovered-…`
|
||||
and placed in the Drive root. Here the blob was already gone and the attempt failed, but with
|
||||
the blob still on disk the copy would land.
|
||||
|
||||
**Fix.** Before keeping a conflict copy, the drain checks that its op is still queued. When the
|
||||
op is gone, the write was withdrawn by the delete, or superseded, while the worker held it, and
|
||||
it is dropped.
|
||||
|
||||
---
|
||||
|
||||
## B104 — Reading a file that was replaced by a rename fails with `EIO`
|
||||
|
||||
**Status:** Fixed (unverified).
|
||||
**Found:** 2026-09-29, by the acceptance case "rename patterns" on My files and on an on-demand
|
||||
folder: `OSError: [Errno 5] Input/output error` at
|
||||
`os.pread(reader, 64, 0)` after `os.replace(replacement, target)` with `reader` open on `target`.
|
||||
The mirror folder, a local disk, passed.
|
||||
|
||||
**Where:** `crates/pdfs-fuse/src/lib.rs` (`Core::remove_replaced`).
|
||||
|
||||
**Cause.** A rename over an existing file first trashes that file, because Drive has no atomic
|
||||
replace. That step forgot the replaced inode outright and evicted its cache and reader, even
|
||||
when a handle was still open on it. The next read through that handle found no inode and
|
||||
failed. `rename(2)`, like `unlink(2)`, keeps a replaced file readable through the handles
|
||||
already open on it. The unlink path (`trash_child`) already did this, but the rename path
|
||||
did not.
|
||||
|
||||
**Fix.** `remove_replaced` now handles an open victim as `unlink` does. It keeps the inode as
|
||||
an unlinked entry, and leaves its cache and reader to the final close. That applies both to a
|
||||
node the server knows and to one whose create is still queued. Its queued ops at first also
|
||||
waited for the final close; B106 moved them back to the trash.
|
||||
|
||||
---
|
||||
|
||||
## B103 — One stuck trash refresh freezes the trash listing until the daemon restarts
|
||||
|
||||
**Status:** Fixed (unverified). The hang itself has not been reproduced on purpose.
|
||||
**Found:** 2026-09-29, while timing the acceptance suite's wide-directory case. Its cleanup trashed
|
||||
the test folder, then waited for it to show up in the trash so it could delete it permanently. It
|
||||
never did. From 06:55 UTC on, every trash listing logged `trash refresh still running; answering
|
||||
with what has materialized` and returned the same 362 entries. No `trash refresh: enumerated` line
|
||||
followed, so the first server call of the refresh never returned.
|
||||
|
||||
**Where:** `crates/pdfs-fuse/src/lib.rs` (`Core::refresh_trash`, `within_trash_call`).
|
||||
|
||||
**Cause.** Trash refreshes are single-flight: a request joins the refresh already running instead
|
||||
of starting another. The refresh awaited its server calls with no deadline, so one call that
|
||||
never returned kept the flag set forever. Every later request joined it, waited out its 20 s
|
||||
budget, and answered with the stale listing.
|
||||
|
||||
**Fix.** Each server call of a refresh (`enumerate_trash_node_uids`, and each `enumerate_nodes`
|
||||
chunk) fails after `TRASH_CALL_TIMEOUT` (120 s). The refresh then ends with an error, clears the
|
||||
flag, and the next request starts a fresh one.
|
||||
|
||||
---
|
||||
|
||||
## B102 — Removing a folder leaves the queued uploads of the files inside it
|
||||
|
||||
**Status:** Fixed (unverified). No daemon carrying the change has run the acceptance suite yet.
|
||||
**Found:** 2026-09-29, after the account run that found B101. Its cleanup removed both test roots
|
||||
and reported "nothing left behind", but `pdfs sync queue` still listed two `revision` ops for
|
||||
`pdfs-acceptance-…/boundaries/reshaped`, each holding about 8 MiB of staged bytes. They retried
|
||||
against the trash forever ("the share has not been writable for 300s").
|
||||
|
||||
**Where:** `crates/pdfs-core/src/db/ops.rs` (`drop_doomed_ops`, used by
|
||||
`Db::replace_ops_with_trash` and `Db::delete_ops_for_uid`), `crates/pdfs-fuse/src/lib.rs`
|
||||
(`Core::delete`, `Core::release_dropped_ops`).
|
||||
|
||||
**Cause.** Two gaps.
|
||||
|
||||
- **The queue could not see which files a folder holds.** Removing a node dropped the ops for it
|
||||
and for anything *queued* beneath it, found through `pending_op.parent_uid`. Only `create` and
|
||||
`mkdir` ops carry a parent, so a `revision` of a file that already existed remotely was never
|
||||
found, however deep in the removed folder it sat.
|
||||
- **`pdfs rm` dropped nothing at all.** The control socket's delete trashed the node remotely but
|
||||
left every op queued for it, unlike an unlink through the mount.
|
||||
|
||||
**Fix.** Removal also follows the node tree (`nodes.parent_uid`) and drops the `revision` ops of
|
||||
every file below the node, with their blobs, pending entries and in-flight uploads. Queued renames
|
||||
below it are kept: one that moved a node into the folder still has to land for the node to go to
|
||||
the trash with it. `pdfs rm` now drops the queued ops once the remote trash succeeds. The
|
||||
acceptance janitor lists the daemon queue after cleanup and reports any op still naming one of its
|
||||
roots as a leftover.
|
||||
Regression test: `trashing_a_folder_drops_the_queued_revisions_of_files_below_it`.
|
||||
|
||||
---
|
||||
|
||||
## B101 — A shrunk file written past its end gets old bytes back, or fails with `EIO`; a write during a create's upload is lost
|
||||
|
||||
**Status:** Fixed (unverified). No daemon carrying the change has run the acceptance suite yet.
|
||||
**Found:** 2026-09-29, by the new acceptance case "block boundaries and overwrites that change
|
||||
the block count". It failed with `Input/output error` in My files and in an on-demand folder, every
|
||||
time. The journal had `the revision's block table disagrees with the node size ... fsize=8388611
|
||||
reader_size=0`, then `pending upload failed; will retry ... gap-fill from base failed: Errno(5)`
|
||||
on every retry.
|
||||
|
||||
**Where:** `crates/pdfs-fuse/src/state.rs` (`WriteHandle::record_write`),
|
||||
`crates/pdfs-fuse/src/drain.rs` (`Core::drain_local_node`, `Core::retire_create`),
|
||||
`crates/pdfs-fuse/src/lib.rs` (`Core::enqueue_staged_write`),
|
||||
`crates/pdfs-core/src/db/ops.rs` (`Db::finish_create`).
|
||||
|
||||
**Cause.** Two defects.
|
||||
|
||||
- **The hole of a write past the end was not authored.** A write handle tracks which ranges it
|
||||
wrote; everything else below the base size is filled from the base when it is committed. A
|
||||
grow through `setattr` marks its new tail as written, but a `pwrite` past the end did not mark
|
||||
the hole it leaves. After `ftruncate(fd, 4 MiB)` and `pwrite(fd, data, 4 MiB + 10)`, bytes
|
||||
4 MiB to 4 MiB + 10 were refilled from the old base instead of reading as zeros. That silently
|
||||
restored truncated bytes. When the base was itself a queued revision the remote did not have
|
||||
yet, the fill read a revision of the wrong size, and B84's check failed it: `EIO` on read, and
|
||||
a drain that retried forever.
|
||||
- **A write that closed while its file's create uploaded was lost.** The create of a new file
|
||||
drains on its own, often while the file is still open. A write that closes meanwhile attaches
|
||||
its blob to the create's queue row. When the create landed, the drain deleted that row by id,
|
||||
so the server kept the older bytes (often an empty file) and nothing was queued to replace
|
||||
them. A handle still open after the create landed carried the placeholder uid, found no create
|
||||
to attach to, and failed its `close` with `EIO`.
|
||||
|
||||
**Fix.** A write past the end authors the hole (`WriteHandle::record_write`).
|
||||
`Db::finish_create` retires a landed create in one transaction: if the row now holds a newer blob
|
||||
than the one uploaded, it becomes a revision of the real uid instead of being deleted. The drain
|
||||
does this under the `pending` lock, which `enqueue_staged_write` holds from attaching a blob until
|
||||
it records the pending entry, and then repoints open write handles to the real uid. A write that
|
||||
still finds the create gone waits for its inode's real uid (`Core::landed_uid`) and queues a
|
||||
revision against it.
|
||||
Regression tests: `a_write_past_the_end_authors_the_hole_it_leaves`,
|
||||
`a_write_attached_while_its_create_uploads_becomes_a_revision`,
|
||||
`a_create_whose_blob_landed_is_retired`, and the live acceptance cases "block boundaries and
|
||||
overwrites that change the block count" (now also read back after the queue drains) and
|
||||
`regression B101`.
|
||||
|
||||
---
|
||||
|
||||
## B100 — A freshly copied file reads as `EIO` or 53 bytes too big
|
||||
|
||||
**Status:** Fixed (unverified). No daemon carrying the change has read a file with a provisional
|
||||
size yet.
|
||||
**Found:** 2026-09-29, checking a 222-file copy into `Documents/pi-backup` on 2.7.0. `find -printf
|
||||
%s` showed 10 small files 52 to 59 bytes bigger than their originals. `cat` of one of them failed
|
||||
with `Input/output error`, and the journal had `the revision's block table disagrees with the
|
||||
node size ... fsize=221 reader_size=169` followed by `repair download did not match the node
|
||||
size`. The node's DB row and `claimed_size` were correct (169). A few minutes later the same files
|
||||
reported the right size and matched the originals byte for byte.
|
||||
|
||||
**Where:** `crates/pdfs-fuse/src/filesystem.rs` (`read`, `serve_open`),
|
||||
`crates/pdfs-fuse/src/lib.rs` (`Core::upgrade_sizes`, `Core::run_size_upgrade`).
|
||||
|
||||
**Cause.** Three things together.
|
||||
|
||||
- A listing built from the cheap enumeration (B12) has no `claimed_size`, so a file reports its
|
||||
ciphertext size until the size upgrade lands. The upgrade's own documentation said reads were
|
||||
unaffected because the revision reader carries its own size.
|
||||
- B84 made that untrue. `read_block` now treats a revision that disagrees with the size it was
|
||||
given as damage: it repairs from the whole-file download, and when that does not match either,
|
||||
it fails the read. With a provisional size the two never match, so every read failed.
|
||||
- `getattr` normally settles the size before answering (B14), which kept this rare. But only
|
||||
`MAX_SIZE_UPGRADES` (8) folders may upgrade at once, and past that cap `upgrade_sizes` returned
|
||||
nothing. The `stat` was answered with the ciphertext size at once, and nothing retried the
|
||||
folder until the next `stat`. A recursive walk over many fresh folders goes past the cap.
|
||||
|
||||
A write open had the same fault: its handle started at the provisional size, so the file would
|
||||
gain the encryption overhead and reading the base would fail.
|
||||
|
||||
**Fix.** `read` and a write `open` settle a provisional size before using it
|
||||
(`Core::resolve_provisional_size`, one node fetch, on the worker that already goes to the
|
||||
network). A folder past the cap is now queued instead of dropped. When a running upgrade ends,
|
||||
its thread takes over the oldest queued folder, so at most 8 threads still run.
|
||||
Regression tests: `a_folder_past_the_cap_is_queued_not_dropped`,
|
||||
`a_finished_batch_hands_its_thread_the_oldest_queued_one`. The read and open paths need a live
|
||||
mount and have no unit test; the acceptance case `regression B100` covers them on a real mount.
|
||||
|
||||
---
|
||||
|
||||
## B99 — Conflict copies in synced folders are logged again on every start and shown as "Resolved"
|
||||
|
||||
**Status:** Fixed (unverified). No daemon carrying the change has been restarted against the
|
||||
|
||||
@@ -11,6 +11,237 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.8.0] — 2026-09-30
|
||||
|
||||
Schema: **35** (unchanged). Requires `proton-drive-rs` **0.7.1**.
|
||||
|
||||
### Added
|
||||
- **`pdfs trash --json`** prints the trash listing as JSON.
|
||||
- **The FUSE acceptance suite tests the signed-in account by default.**
|
||||
`scripts/fuse-acceptance.sh` with no arguments creates test folders in My files and new
|
||||
on-demand and mirror sync folders, runs every case in each, and removes all of it afterwards,
|
||||
also on a failure or interrupt. A run that dies is cleaned up by the next one. New cases cover
|
||||
block boundaries, unusual names, a deep tree, a wide directory, rename patterns, handle coherency,
|
||||
throughput and B100. `--offline-only` runs the account-free suite as before.
|
||||
|
||||
### Changed
|
||||
- **The mount keeps working through a network drop.** When Drive stops answering, the daemon
|
||||
now marks itself offline within about 20 seconds instead of failing every call for up to two
|
||||
minutes. While offline, creating, renaming and deleting files and folders is queued instead of
|
||||
failing with `Input/output error`, folders list from the local cache, and a read that needs
|
||||
data from Drive waits up to 30 seconds for the connection. The daemon checks every few seconds
|
||||
for the connection to return, then uploads the queued changes. A file whose creation timed
|
||||
out but reached Drive anyway is taken over rather than uploaded again as a conflict copy.
|
||||
- **A dead connection is noticed sooner.** `proton-drive-rs` 0.7.1 sends TCP keepalives, so a
|
||||
connection that died silently fails instead of waiting for its request timeout.
|
||||
|
||||
### Fixed
|
||||
- **Truncating and then writing past the end no longer brings old bytes back.** The gap
|
||||
between the new end and the write now reads as zeros, as it should, instead of the file's
|
||||
earlier content. When that earlier content had not been uploaded yet, the file failed with
|
||||
`Input/output error` and its upload retried forever (B101).
|
||||
- **A write made while a new file is first uploading is no longer lost.** Drive could keep an
|
||||
empty or older copy of a file written right after it was created, and closing a file held open
|
||||
across its first upload could fail (B101).
|
||||
- **Removing a folder also cancels the pending uploads of the files inside it.** Before, an
|
||||
edit that had not reached Drive yet kept retrying against the trash forever and kept its
|
||||
staged copy on disk. `pdfs rm` now also drops what was queued for what it removes (B102).
|
||||
- **A program reading a file keeps its data when another program replaces the file.** Saving
|
||||
over a file by renaming a new copy onto it (what most editors and `os.replace` do) made
|
||||
reads through handles already open on the old file fail with `Input/output error` (B104).
|
||||
- **Short-lived files no longer come back as conflict copies.** A file deleted right after it
|
||||
was written, such as a SQLite journal, could be uploaded as a `recovered-…` conflict copy in
|
||||
the Drive root (B105).
|
||||
- **Editors can save over a file several times in a row.** The fix for B104 could leave the
|
||||
upload of a replaced file queued, and the next save onto the same name failed with
|
||||
`Input/output error` (B106).
|
||||
- **Renaming a file back right after renaming it no longer fails.** Drive can briefly call the
|
||||
second rename out of date, and the daemon now waits it out instead of returning an I/O error
|
||||
(B111).
|
||||
- **Reading a freshly listed file no longer hangs.** When a read had to fetch the file's real
|
||||
size first, the daemon could wait on the read it was serving, and the program froze until
|
||||
the daemon restarted (B110).
|
||||
- **Databases and other files kept open while written no longer read back empty.** When the
|
||||
folder was listed again while a file was still open, its size dropped to what Drive held,
|
||||
often 0 bytes, and SQLite reported `database disk image is malformed` (B109).
|
||||
- **Deleting files during a mirror pass no longer marks the folder as failed.** A file removed
|
||||
after the pass planned its upload was counted as an error, so the folder showed `error` until
|
||||
a later pass (B108).
|
||||
- **A read no longer hangs when the connection stalls.** A read that gets no answer from Drive
|
||||
within two minutes now fails with `Input/output error` instead of blocking the program for
|
||||
as long as half an hour (B107).
|
||||
- **The trash no longer freezes after a server call hangs.** Until the daemon restarted, the
|
||||
trash kept showing the same entries, and a folder just deleted could not be found there to
|
||||
delete permanently (B103).
|
||||
- **Freshly listed files read correctly.** A file whose real size had not been fetched yet
|
||||
reported its encrypted size, about 53 bytes too big, and reading it failed with
|
||||
`Input/output error`. This happened most often right after copying a large folder. Reads and
|
||||
write opens now fetch the real size first, and a big folder walk no longer skips size lookups
|
||||
(B100).
|
||||
|
||||
## [2.7.0] — 2026-09-29
|
||||
|
||||
Schema: **35** (unchanged).
|
||||
|
||||
### Added
|
||||
- **Moves between locations without transferring content.** `pdfs move` and the app's
|
||||
**Move to…** now take a file or folder from My files into a synced folder, or between two
|
||||
synced folders, on-demand or mirrored. The move happens on Proton Drive, so nothing is
|
||||
downloaded or uploaded again. `mv` between two locations still copies, because the kernel
|
||||
refuses a rename between two mounts. The app's Move dialog lists every location at its top
|
||||
level. A move is refused, with the reason, when the source still has changes that are not on
|
||||
Proton Drive, when the two locations are on different volumes, or when a synced folder stays
|
||||
busy with a sync pass.
|
||||
- **`pdfs move` takes several sources.** `pdfs move a b c DEST` moves all three and
|
||||
reports each one that could not move.
|
||||
- **Moving into a mirrored folder reuses cached content.** A file already cached by
|
||||
an online-only folder is copied into place instead of downloaded again.
|
||||
|
||||
### Changed
|
||||
- **New project homepage: [proton-drive.narl.io](https://proton-drive.narl.io).** The About
|
||||
dialog's website link, the README, and the package metadata (Cargo, PKGBUILDs, `.deb`,
|
||||
`.rpm`) point there. The source code link stays on GitHub.
|
||||
|
||||
## [2.6.2] — 2026-09-28
|
||||
|
||||
Schema: **35** (unchanged).
|
||||
|
||||
### Fixed
|
||||
- **Photos remembers the Albums and Places views.** Leaving Photos from Albums or Places and
|
||||
coming back showed the timeline while the Albums or Places toggle stayed selected. The page
|
||||
now reopens on the selected view, and Places keeps its Grid or Map layout.
|
||||
- **The Sync page keeps a steady width.** A long file name in Transfers no longer widens the
|
||||
page. Overview and Folders now fill the width next to the sidebar.
|
||||
|
||||
## [2.6.1] — 2026-09-28
|
||||
|
||||
Schema: **35** (unchanged).
|
||||
|
||||
### Added
|
||||
- **`pdfs-prompt --preload`.** Starts the launcher hidden, for session autostart such as
|
||||
Hyprland's `exec-once`, so the first shortcut opens it as fast as later ones. It does nothing
|
||||
when the launcher is already running.
|
||||
|
||||
### Fixed
|
||||
- **Launcher invocations no longer pile up.** Each shortcut press after the first used to leave
|
||||
a `pdfs-prompt` process behind after handing the request to the running launcher. It now exits.
|
||||
|
||||
## [2.6.0] — 2026-09-28
|
||||
|
||||
The search launcher (`pdfs-prompt`) is rebuilt around a preview pane, and it now looks like the
|
||||
app it belongs to.
|
||||
|
||||
Schema: **35** (unchanged).
|
||||
|
||||
### Added
|
||||
- **Launcher preview pane.** The selected result shows a large thumbnail, its type, size,
|
||||
modified date, location and whether it is on this computer, with Open (Enter), Show in Folder
|
||||
(Ctrl+Enter) and Copy Path (Ctrl+C, or Ctrl+Shift+C while text is selected in the search box).
|
||||
Show in Folder selects the file in the file manager through `org.freedesktop.FileManager1`
|
||||
and falls back to opening the folder.
|
||||
- **Recent files.** With an empty search box, the launcher lists the last files opened from it
|
||||
above the pinned ones. Files opened often and lately also rank higher in search results. The
|
||||
history lives in `prompt-history.json` in the state directory and forgets local files that
|
||||
no longer exist.
|
||||
- **Thumbnails in the launcher.** Images show a thumbnail in the result list and the preview,
|
||||
from the desktop's thumbnail cache, decoded locally (and written back to that cache), or
|
||||
fetched by the daemon for Drive files.
|
||||
- **Drag and drop from the launcher.** A result can be dragged into a file manager or another
|
||||
application as a file.
|
||||
|
||||
### Changed
|
||||
- **The launcher follows the app's theme.** It uses the system accent colour, light and dark
|
||||
style, and the opt-in Proton theme, instead of always pinning Proton purple. Changes to the
|
||||
theme or to `open_with` apply the next time the launcher opens.
|
||||
- **Launcher layout.** Results are grouped into Recent, Pinned in Proton Drive and Best matches;
|
||||
matched characters are bold, and each row shows its sync state, size and age. A click selects
|
||||
a row and a double-click opens it; the search box keeps the keyboard focus throughout.
|
||||
- **Faster search as you type.** The launcher searches 80 ms after the last keystroke instead
|
||||
of 120 ms.
|
||||
|
||||
### Fixed
|
||||
- **The launcher no longer freezes on a stuck mount.** Checking whether a Drive file is in the
|
||||
mount runs off the main thread and gives up after 2 seconds, falling back to the daemon.
|
||||
Escape cancels a Drive file that is still opening.
|
||||
|
||||
### Documentation
|
||||
- **Rewritten user documentation.** New pages for installation, the user guide, the command-line
|
||||
reference, configuration and troubleshooting, and a documentation index in `docs/README.md`.
|
||||
The README is shorter and points to them.
|
||||
- **Recovery** describes the current restore flow (adopting a computer, `pdfs sync restore`, the
|
||||
machine profile) instead of the old manual runbook.
|
||||
- **Architecture** covers push events, remote event streams, the revision block geometry, the
|
||||
thread layout of 2.5.0 and the machine profile. The development page is now a developer guide,
|
||||
and open work moved to `docs/ROADMAP.md`.
|
||||
- `scripts/publish-wiki.sh` publishes the user pages to the GitHub wiki.
|
||||
|
||||
## [2.5.0] — 2026-09-28
|
||||
|
||||
A polish pass over the desktop app: the daemon pushes changes instead of being polled, pages
|
||||
stay up while they reload, Trash and the shared pages use the same file list as My files, the
|
||||
Sync page is split into views, the Computers page can browse another computer's backup, and
|
||||
Photos gains a timeline scrubber.
|
||||
|
||||
Schema: **35**. The daemon notes when it first sees an item in the trash, since Proton does not
|
||||
report the trash time to this client yet.
|
||||
|
||||
### Added
|
||||
- **Browse other computers.** Opening another computer on the Computers page shows its backup
|
||||
read-only, in the same list and grid as My files, and files open with a double-click.
|
||||
- **Trash shows where items came from and when they were deleted.** The date is when this client
|
||||
first saw the item in the trash until Proton reports the exact time. Restoring offers a Show
|
||||
action, and opening a trashed item explains that it must be restored first.
|
||||
- **Shared with me** shows who shared each item, with a warning for unverified senders, the
|
||||
access it gives and when it was shared. Pending invitations wait in a banner, several items can
|
||||
be left at once, and saved links have their own view.
|
||||
- **Shared by me** gains a filter (links, people, expired links), columns for location, people
|
||||
and link state, a copy button for live links, and stopping several shares at once.
|
||||
- **Sidebar badges** count sync problems and waiting invitations.
|
||||
|
||||
### Changed
|
||||
- **The app and the tray no longer poll.** The daemon pushes its status, its transfers and what
|
||||
changed over one control connection (`Request::Subscribe`), and the window reloads the page on
|
||||
screen when that page's data changes, including changes made with the `pdfs` CLI. A hidden
|
||||
window keeps the changes and catches up when it is focused. Front-ends fall back to polling a
|
||||
daemon that predates subscriptions until its service restarts.
|
||||
- **Refresh buttons are gone** from the page headers now that pages follow the daemon. F5 and
|
||||
Ctrl+R still reload the page from the server.
|
||||
- **Photos timeline scrubber.** A strip along the right edge of the timeline shows the years
|
||||
and appears when the pointer comes near or the timeline scrolls. Hovering names the month
|
||||
under the pointer, and dragging jumps to it. It replaces the slider above the grid and the
|
||||
scrollbar.
|
||||
- **Upload is a split button** on the Photos page. Its menu holds "Import from Google Photos…",
|
||||
and the page menu keeps "Find Duplicates…".
|
||||
- **Quieter photo tiles.** Hovering enlarges the thumbnail slightly instead of lifting the tile
|
||||
with a shadow, and tiles no longer show a tooltip. Tiles are reused while scrolling rather than
|
||||
rebuilt for every row.
|
||||
- **Newer libadwaita widgets where available.** On libadwaita 1.8 or newer the app uses its
|
||||
spinner, toggle groups, button rows and shortcuts dialog, and on older releases it keeps
|
||||
look-alike stand-ins. The choice is made at run time, so every package gets them and the app
|
||||
still builds and runs on libadwaita 1.5. Set `PDFS_STAND_IN_WIDGETS=1` to see the stand-ins
|
||||
on a newer system.
|
||||
- **Sync page views.** Overview holds the status, conflicts, transfers and queue; Folders holds
|
||||
the folder list, with a short explanation of the sync modes; History is the former Activity
|
||||
page. A banner points back to Overview when changes fail or conflict. Each folder is one card
|
||||
with a Synced / Online only switch, and switching either way asks first.
|
||||
- **Activity reads better.** Temporary files that editors save through are hidden (a menu shows
|
||||
them again), runs of similar events fold into one expandable row, entries are full translated
|
||||
sentences, and "Show Older" reaches further back.
|
||||
- **Computers** lists every computer in one group, this one first. "Use This Computer's
|
||||
Identity" is now "Continue This Backup Here", with an explanation in its dialog, and "never
|
||||
synced" reads "no backups yet".
|
||||
- **Pages stay up while they reload.** A loading state shows only when a reply takes longer than
|
||||
300 ms, and a refresh keeps the selection, scroll position and thumbnails.
|
||||
- **One glossary.** "Proton Drive service" replaces "mount service" and "daemon", and "Online
|
||||
only" replaces "On-demand"; dialogs use title case, and dates and sizes are formatted one way.
|
||||
- **Bundled icons** replace glyphs that stock icon themes lack or draw ambiguously.
|
||||
|
||||
### Fixed
|
||||
- **Enabling, disabling or restarting the service no longer freezes the window.**
|
||||
- **A daemon that runs but doesn't answer** reads as "Not responding" instead of disconnected,
|
||||
and requests to it give up after 5 s instead of 120 s.
|
||||
- The Sync and share-link icons drew only half of themselves on recent GTK versions.
|
||||
|
||||
## [2.4.0] — 2026-09-28
|
||||
|
||||
Schema: **34**. Photos gain a `similar_hash` column for the similar-photo finder. It stays
|
||||
@@ -253,7 +484,7 @@ Schema: **31** (unchanged).
|
||||
opened in between.
|
||||
- **Photo viewer rewrite.** The lightbox's controls fade out after two seconds without pointer
|
||||
movement. Photos zoom with Ctrl+scroll, a pinch, `+`/`-`, `1` for 100 % and double-click, and
|
||||
pan by dragging; `0` fits them again. The details panel docks beside the photo instead of
|
||||
pan by dragging; `0` fits them again. The details panel docks beside the photo instead of 120 ms
|
||||
covering it. Stepping past the last loaded photo loads the next page, and the counter shows the
|
||||
library's total rather than the loaded count. A video plays inline. Save a Copy uses the photo's
|
||||
real name and reports failures, and Open With… offers the app chooser.
|
||||
@@ -591,7 +822,7 @@ thumbnail.
|
||||
slider for the icon grid, and account storage usage. Zoom resizes the realised grid cells rather
|
||||
than rebuilding the model, so dragging the slider does not tear down the selection; the default
|
||||
size reproduces the previous fixed 72 px tiles. Capacity stays out of the bar until a real
|
||||
reading lands, and the quota fetch (60 s TTL) now also runs while Files is on screen instead of
|
||||
reading lands, and the quota fetch (60 s TTL) now also runs while Files is on screen instead of 120 ms
|
||||
only in Settings.
|
||||
- Repository housekeeping: `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, issue and pull
|
||||
request templates, a project logo, and workspace crate metadata (`repository`, `homepage`,
|
||||
@@ -1110,8 +1341,14 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
|
||||
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
||||
by a new POSIX compliance suite for the filesystem.
|
||||
|
||||
[2.4.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.4.0
|
||||
[2.8.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.8.0
|
||||
[2.7.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.7.0
|
||||
|
||||
[2.6.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.6.2
|
||||
[2.6.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.6.1
|
||||
[2.6.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.6.0
|
||||
[2.5.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.5.0
|
||||
[2.4.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.4.0
|
||||
[2.3.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.3.0
|
||||
[2.2.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.2.2
|
||||
[2.2.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.2.1
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
# Command-line reference
|
||||
|
||||
`pdfs` controls the Proton Drive service from a terminal and from scripts. Almost every command
|
||||
talks to the running service (`proton-drive.service`); `login`, `logout`, `mount`, `daemon` and
|
||||
`diagnose` work without it.
|
||||
|
||||
`pdfs <command> --help` prints the full help of any command. This page groups them by task.
|
||||
|
||||
- [Conventions](#conventions)
|
||||
- [Account and service](#account-and-service)
|
||||
- [Files](#files)
|
||||
- [Available offline](#available-offline)
|
||||
- [Synced folders](#synced-folders)
|
||||
- [Conflicts](#conflicts)
|
||||
- [Computers](#computers)
|
||||
- [Trash](#trash)
|
||||
- [Versions](#versions)
|
||||
- [Sharing](#sharing)
|
||||
- [Photos](#photos)
|
||||
- [Activity and transfers](#activity-and-transfers)
|
||||
- [Maintenance and diagnostics](#maintenance-and-diagnostics)
|
||||
- [Scripting with `--json`](#scripting-with---json)
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Paths** name items in My files. They may be absolute paths inside the Proton Drive folder
|
||||
(`~/ProtonDrive/Documents/a.pdf`) or relative to it (`Documents/a.pdf`).
|
||||
- **UIDs** identify items that have no path in the mount: photos, trashed items, shared items and
|
||||
computers. The listing commands print them.
|
||||
- **Exit codes**: `0` on success, non-zero on any failure, including an error reported by the
|
||||
service.
|
||||
- **Output** is English and stable enough for `grep`; use `--json` where it is available.
|
||||
|
||||
## Account and service
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `pdfs login [-u EMAIL]` | Sign in (password, then a 2FA code if the account has one) and store the session in the system keyring. Restarts the service if it is running. |
|
||||
| `pdfs logout` | Remove the stored session. |
|
||||
| `pdfs status` | Account, mount, queue and cache state. |
|
||||
| `pdfs quota` | Storage used across all Proton products. |
|
||||
| `pdfs locations` | Every local place the service occupies: the Proton Drive folder and each synced folder, with mode and state. |
|
||||
| `pdfs daemon [MOUNTPOINT]` | Run the service in the foreground: wait for a session, mount, stay mounted. `proton-drive.service` runs this. |
|
||||
| `pdfs mount [MOUNTPOINT]` | Mount once and block until unmounted. Defaults to `~/ProtonDrive`. |
|
||||
|
||||
`pdfs login` cannot show a CAPTCHA. If Proton asks for one, sign in with `pdfs-app` instead.
|
||||
|
||||
## Files
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `pdfs ls [PATH]` | List a folder. |
|
||||
| `pdfs search QUERY [--in FOLDER] [--limit N]` | Search names in the local index. |
|
||||
| `pdfs mkdir PARENT NAME` | Create a folder. |
|
||||
| `pdfs rename PATH NEW_NAME` | Rename a file or folder. |
|
||||
| `pdfs move PATH... NEW_PARENT` | Move one or more files or folders into another folder. The two may be in different locations (My files, an on-demand or a mirrored synced folder); the move happens on Proton Drive, without downloading or uploading anything. |
|
||||
| `pdfs rm PATH` | Move to the Trash. |
|
||||
| `pdfs upload SOURCES... [-t FOLDER]` | Upload local files and folders (recursively) in the background. Follow with `pdfs transfers`. |
|
||||
| `pdfs refresh [TARGET] [--full]` | Forget a cached listing so the next read fetches it again. `TARGET` is a folder, `trash` or `photos`; `--full` with `photos` re-reads every photo's metadata. |
|
||||
|
||||
Ordinary file tools (`cp`, `mv`, `rsync`, editors) work on the Proton Drive folder directly. These
|
||||
commands are for scripts and for cases where the mount is not available.
|
||||
|
||||
## Available offline
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `pdfs pin PATH` | Download a file completely and keep it on this computer. |
|
||||
| `pdfs unpin PATH` | Release it back to online only. |
|
||||
| `pdfs pins` | List files kept available offline. |
|
||||
|
||||
## Synced folders
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `pdfs sync add PATH` | Back up a local folder under this computer and keep it in sync. |
|
||||
| `pdfs sync list` | List synced folders with their IDs and state. |
|
||||
| `pdfs sync rm ID [--delete-remote]` | Stop syncing. Local files stay; `--delete-remote` also removes the copy on Proton Drive. |
|
||||
| `pdfs sync mode ID mirror\|ondemand` | Switch between a full local copy (`mirror`) and online only (`ondemand`). |
|
||||
| `pdfs sync now [ID]` | Start a sync pass now, for all folders or one. |
|
||||
| `pdfs sync pause [--for 1h] [--folder ID]` | Pause uploads and synced folders. Reading files keeps working. `--for` ends the pause by itself (`30m`, `1h`, `2d`). |
|
||||
| `pdfs sync resume [--folder ID]` | Resume after a pause. |
|
||||
| `pdfs sync limit [--up RATE] [--down RATE]` | Cap bandwidth, for example `500K` or `2M` per second. `0` removes a cap. Without options, prints the current caps. |
|
||||
| `pdfs sync queue` | List changes that have not reached Proton Drive yet. |
|
||||
| `pdfs sync retry [ID]` | Retry one queued operation, or every failed one. |
|
||||
| `pdfs sync restore [--device UID] [--yes]` | Re-attach this computer's backed-up folders to local folders and download them. `--device` restores another computer's folders. See [RECOVERY.md](RECOVERY.md). |
|
||||
|
||||
## Conflicts
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `pdfs conflicts` | List `(sync-conflict …)` copies. |
|
||||
| `pdfs conflicts resolve PATH --keep original\|copy\|both [--name NAME]` | `original` trashes the copy; `copy` trashes the original and gives the copy its name; `both` renames the copy to `--name`. |
|
||||
|
||||
## Computers
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `pdfs devices list` | List computers registered to the account. |
|
||||
| `pdfs devices rename UID NAME` | Rename a computer. |
|
||||
| `pdfs devices rm UID` | Remove a computer and its backup from the account. |
|
||||
| `pdfs devices adopt UID` | Make this machine continue another computer's backup, for example after a reinstall. `--clear` returns to matching by hostname. |
|
||||
|
||||
## Trash
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `pdfs trash` | List the Trash with the UIDs the next commands take. |
|
||||
| `pdfs restore UIDS...` | Put items back where they were. |
|
||||
| `pdfs delete-forever UIDS...` | Delete items permanently. |
|
||||
| `pdfs empty-trash` | Delete everything in the Trash permanently. |
|
||||
|
||||
## Versions
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `pdfs versions list PATH` | List a file's versions, newest first. |
|
||||
| `pdfs versions restore PATH ID` | Make an earlier version current. Runs on the server; nothing is re-uploaded. |
|
||||
| `pdfs versions save PATH ID DEST` | Write an earlier version to a local file. Refuses to overwrite `DEST`. |
|
||||
| `pdfs versions rm PATH ID` | Delete an earlier version permanently. The current version cannot be deleted. |
|
||||
|
||||
## Sharing
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `pdfs share PATH EMAILS... [--role viewer\|editor\|admin] [--message TEXT]` | Invite people to a file or folder. |
|
||||
| `pdfs members PATH` | List members, pending invitations and the public link. |
|
||||
| `pdfs share-role PATH ID KIND ROLE` | Change a member's or invitation's role. |
|
||||
| `pdfs unshare PATH [ID KIND]` | Stop sharing, or remove one member or invitation. |
|
||||
| `pdfs public-link create PATH [--role] [--password] [--expires EPOCH]` | Create a public link. `--expires` takes Unix seconds. |
|
||||
| `pdfs public-link remove PATH ID` | Remove a public link. |
|
||||
| `pdfs shared` | List what you share. |
|
||||
| `pdfs shared-with-me [UID]` | List what others share with you, or the contents of one shared folder. |
|
||||
| `pdfs shared-get UID [DEST]` | Download a file shared with you. |
|
||||
| `pdfs leave UID` | Leave a share. |
|
||||
| `pdfs invitations list\|accept ID\|reject ID` | Manage invitations addressed to you. |
|
||||
| `pdfs bookmarks list\|add URL [--password]\|rm TOKEN` | Manage saved public links. |
|
||||
|
||||
## Photos
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `pdfs photos [--limit N] [--offset N] [--favorites]` | List the timeline, newest first. |
|
||||
| `pdfs open-photo UID` | Download a photo and print its cached path. |
|
||||
| `pdfs favorite UID [--remove]` | Mark or unmark a favorite. |
|
||||
| `pdfs albums` | List albums, including those shared with you. |
|
||||
| `pdfs album UID` | List an album's photos. |
|
||||
| `pdfs new-album NAME` | Create an album and print its UID. |
|
||||
| `pdfs rename-album UID NAME` | Rename an album. |
|
||||
| `pdfs delete-album UID` | Delete an album. Its photos stay in the timeline. |
|
||||
| `pdfs add-to-album UID PHOTOS...` | Add photos to an album. |
|
||||
| `pdfs remove-from-album UID PHOTOS...` | Take photos out of an album. |
|
||||
| `pdfs import-google-photos ARCHIVES... [--dry-run] [--wait]` | Import a Google Takeout export. Pass every `.zip` of the export. |
|
||||
| `pdfs import-status` / `pdfs cancel-import` | Follow or stop a running import. |
|
||||
| `pdfs redate-photos [--dry-run] [--from DATE] [--to DATE] [--limit N] [--wait]` | Fix photos filed under their upload day when the file name carries the real date. |
|
||||
| `pdfs redate-status` / `pdfs cancel-redate` | Follow or stop a running re-date. |
|
||||
|
||||
## Activity and transfers
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `pdfs activity [-l N]` | Recent activity, newest first. |
|
||||
| `pdfs transfers` | Uploads and downloads in progress. |
|
||||
|
||||
## Maintenance and diagnostics
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `pdfs diagnose` | Check the installation and print a report. Works without the service. Exits non-zero if a check fails. |
|
||||
| `pdfs diagnostics` | What a running service is doing now: worker threads, queues, requests in flight, memory. For a service that has stopped answering. |
|
||||
| `pdfs cache inspect [--deep]` | Database size, reclaimable space, row counts, cache use against the limit. `--deep` also runs SQLite's integrity check (slow). |
|
||||
| `pdfs cache vacuum` | Compact the database. Needs free space for a second copy of it while it runs. |
|
||||
| `pdfs cache clear` | Delete cached file content. Files kept available offline stay. |
|
||||
|
||||
[TROUBLESHOOTING.md](TROUBLESHOOTING.md) explains when to use each of these.
|
||||
|
||||
## Scripting with `--json`
|
||||
|
||||
The global `--json` flag makes query commands print JSON: `status`, `ls`, `pins`, `sync list`,
|
||||
`devices list`, `locations`, `transfers`, `activity`, `trash` and `cache inspect`. Commands that change
|
||||
something keep their human-readable output; check their exit code.
|
||||
|
||||
```bash
|
||||
pdfs --json status | jq -r '.mount.mountpoint'
|
||||
pdfs --json sync list | jq -r '.items[] | select(.state != "idle") | .local_path'
|
||||
pdfs --json cache inspect | jq '.db_reclaimable_bytes'
|
||||
```
|
||||
|
||||
- The payload is unwrapped: a list is `{"items": [...]}`, never the service's internal message
|
||||
name.
|
||||
- An error from the service is printed as JSON with a machine-readable `kind` and still exits
|
||||
non-zero, so `set -e` and `if pdfs …` behave as expected.
|
||||
@@ -0,0 +1,201 @@
|
||||
# Configuration
|
||||
|
||||
Most settings are in the app under **Preferences**. This page documents the configuration file
|
||||
behind them, the settings that exist only there, the environment variables the programs read,
|
||||
and where the client keeps its data.
|
||||
|
||||
- [Files and directories](#files-and-directories)
|
||||
- [`config.json`](#configjson)
|
||||
- [Environment variables](#environment-variables)
|
||||
- [The systemd service](#the-systemd-service)
|
||||
|
||||
## Files and directories
|
||||
|
||||
| Path | Contents |
|
||||
|---|---|
|
||||
| `~/.config/proton-drive-linux/config.json` | Settings (this page) |
|
||||
| `~/.local/state/proton-drive-linux/cache.db` | Metadata database: names, folder tree, sync state, upload queue, photo index |
|
||||
| `~/.local/state/proton-drive-linux/sdk_cache.db` | Encrypted cache of Proton Drive metadata. Safe to delete while the service is stopped |
|
||||
| `~/.local/state/proton-drive-linux/control.sock` | Socket the app, tray, prompt and CLI use to reach the service |
|
||||
| `~/.cache/proton-drive-linux/content/` | File content cache, see below |
|
||||
| `~/ProtonDrive` | The Proton Drive folder (default mountpoint) |
|
||||
| System keyring | The session. Never written to disk by the client |
|
||||
|
||||
The directories honour `XDG_CONFIG_HOME`, `XDG_STATE_HOME` and `XDG_CACHE_HOME`. They must be real
|
||||
directories owned by you with mode `0700`; the service refuses to start otherwise.
|
||||
|
||||
Inside `content/`:
|
||||
|
||||
| Directory | Contents | Safe to delete? |
|
||||
|---|---|---|
|
||||
| top level, `blocks/`, `thumbs/` | Cached file content and thumbnails | Yes — use `pdfs cache clear` |
|
||||
| `scratch/` | Files open for writing right now | **No** |
|
||||
| `staging/` | Closed files waiting to upload | **No** — may be the only copy |
|
||||
| `recovery/` | Writes rescued after a crash, waiting to be queued | **No** — may be the only copy |
|
||||
|
||||
> [!CAUTION]
|
||||
> Never delete `staging/` or `recovery/` by hand. Check `pdfs sync queue` first. Content there has
|
||||
> not reached Proton Drive and exists nowhere else.
|
||||
|
||||
Everything in these directories except the keyring is **decrypted**. See
|
||||
[ARCHITECTURE.md §8](ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||
|
||||
## `config.json`
|
||||
|
||||
The file is created with defaults on first start. Every key is optional; a missing key takes its
|
||||
default. Edit it while the app is closed, then restart the service
|
||||
(`systemctl --user restart proton-drive.service`) for service settings to apply.
|
||||
|
||||
An unreadable file is never overwritten: the programs log an error and run with defaults until you
|
||||
fix it.
|
||||
|
||||
```json
|
||||
{
|
||||
"mountpoint": "/home/you/ProtonDrive",
|
||||
"cache_budget": 10737418240,
|
||||
"upload_limit": null,
|
||||
"download_limit": 5242880,
|
||||
"ignore_patterns": ["node_modules/", "target/", "*.iso"],
|
||||
"language": "de",
|
||||
"proton_theme": true,
|
||||
"online_map": false,
|
||||
"tray_hidden": false,
|
||||
"files_view": { "list": true, "sort": "modified", "descending": true, "folders_first": true },
|
||||
"open_with": {
|
||||
"terminal": ["alacritty", "-e"],
|
||||
"rules": [{ "match": ["@text"], "command": ["nvim"], "terminal": true }]
|
||||
},
|
||||
"prompt": { "mode": "fzf", "menu_limit": 50 }
|
||||
}
|
||||
```
|
||||
|
||||
### Reference
|
||||
|
||||
| Key | Type | Default | In the app | Meaning |
|
||||
|---|---|---|---|---|
|
||||
| `mountpoint` | path | `~/ProtonDrive` | General → Location | Where My files is mounted |
|
||||
| `cache_budget` | bytes | 5 GiB | Storage → Cache | Soft limit for cached content. `0` means unlimited. Available-offline files do not count against it |
|
||||
| `upload_limit` | bytes/s | none | General → Network | Shared cap for all uploads. `0` or `null` means none |
|
||||
| `download_limit` | bytes/s | none | General → Network | Shared cap for all downloads, reads from the mount included |
|
||||
| `ignore_patterns` | list | see below | — | Global ignore rules for synced folders |
|
||||
| `device_uid` | string | none | Computers → Continue This Backup Here… | The computer this machine continues. Unset means "match by hostname" |
|
||||
| `conflict_sweep` | `off` / `report` / `enforce` | `report` | — | See [`conflict_sweep`](#conflict_sweep) |
|
||||
| `language` | gettext code | system | General → Appearance | Language of the app, tray and prompt, such as `de` or `pt_BR` |
|
||||
| `proton_theme` | bool | follow system | General → Appearance | Proton's colours instead of the system theme |
|
||||
| `online_map` | bool | `false` | General → Appearance | Street map for Places from OpenFreeMap |
|
||||
| `tray_hidden` | bool | `false` | General → Appearance | Hide the tray icon |
|
||||
| `files_view` | object | grid, by name | My files view menu | `list`, `sort` (`name`, `size`, `modified`), `descending`, `folders_first` |
|
||||
| `open_with` | object | `xdg-open` | — | See [`open_with`](#open_with) |
|
||||
| `prompt` | object | built-in window | — | See [`prompt`](#prompt) |
|
||||
|
||||
`app_version` and `user_agent` identify the client to Proton. Leave them alone.
|
||||
|
||||
### `ignore_patterns`
|
||||
|
||||
Gitignore-style rules applied to every synced folder, in addition to each folder's own
|
||||
`.pdfsignore`. When the key is absent, these defaults apply:
|
||||
|
||||
```json
|
||||
[".git/", ".hg/", ".svn/", "node_modules/", "target/", ".venv/", "__pycache__/",
|
||||
"*~", "*.swp", "*.tmp", ".DS_Store", "Thumbs.db"]
|
||||
```
|
||||
|
||||
Set it to `[]` to turn the defaults off. See
|
||||
[USER_GUIDE.md](USER_GUIDE.md#excluding-files-with-pdfsignore) for how ignoring behaves.
|
||||
|
||||
### `conflict_sweep`
|
||||
|
||||
A background task looks for `(sync-conflict …)` copies that are byte-identical to the file they
|
||||
were copied from.
|
||||
|
||||
| Value | Behaviour |
|
||||
|---|---|
|
||||
| `off` | Do not run. |
|
||||
| `report` | Log and show what would be removed; remove nothing. **Default.** |
|
||||
| `enforce` | Move copies proven identical (same size and SHA-1) to the Trash. |
|
||||
|
||||
`PDFS_CONFLICT_SWEEP` overrides the setting for one run of the service.
|
||||
|
||||
### `open_with`
|
||||
|
||||
Decides how files picked in the search launcher or the app are opened. Rules are tried in order;
|
||||
the first match wins.
|
||||
|
||||
```json
|
||||
{
|
||||
"open_with": {
|
||||
"terminal": ["alacritty", "-e"],
|
||||
"default": ["xdg-open"],
|
||||
"rules": [
|
||||
{ "match": ["@text", "*.rs", "*.toml"], "command": ["nvim"], "terminal": true },
|
||||
{ "match": ["*.png", "*.jpg"], "command": ["imv"] },
|
||||
{ "match": ["@document"], "command": ["$EDITOR", "{}"], "terminal": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `match` takes file-name globs (`*.md`, `notes-*.txt`) or the classes `@dir`, `@text`,
|
||||
`@document`, `@image`, `@media` and `@any`. Patterns are matched against the name in Drive, not
|
||||
against the cached file, which is stored under a content hash.
|
||||
- `command` is an argument list, not a shell line. `{}` is replaced by the path; without it the
|
||||
path is appended. `$VAR` expands from the environment.
|
||||
- `"terminal": true` runs the command inside `terminal`. When `terminal` is unset, the client uses
|
||||
`$PDFS_TERMINAL`, then `$TERMINAL`, then the first known terminal on `PATH`, and adds that
|
||||
terminal's "run this command" flag for you.
|
||||
- `default` replaces `xdg-open` for files no rule matches.
|
||||
|
||||
### `prompt`
|
||||
|
||||
Settings for `pdfs-prompt`.
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `mode` | `gtk` | Front end used without a flag: `gtk`, `dmenu` or `fzf` |
|
||||
| `menu` | first installed of fuzzel, rofi, wofi, tofi, bemenu, dmenu | Launcher for `dmenu` mode, as an argument list. `{prompt}` is replaced by the prompt text; without it, the launcher's own prompt flag is added |
|
||||
| `terminal` | first installed of foot, ghostty, kitty, alacritty, wezterm, xterm | Terminal for `fzf` mode. `{cmd}` is replaced by the command; without it, the command is appended |
|
||||
| `menu_limit` | `50` | Results shown in `dmenu` and `fzf` mode |
|
||||
| `menu_icons` | `true` | File-type icons in launchers that support them |
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": {
|
||||
"mode": "dmenu",
|
||||
"menu": ["fuzzel", "--dmenu", "--width", "60"],
|
||||
"terminal": ["foot", "--app-id=pdfs-prompt", "--"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Command-line flags win over the file: `--gtk`, `--dmenu`, `--fzf` and `--menu`.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Read by | Effect |
|
||||
|---|---|---|
|
||||
| `RUST_LOG` | all | Log filter, for example `RUST_LOG=debug` or `RUST_LOG=pdfs_fuse=debug,info`. Default `info` |
|
||||
| `PDFS_CONFLICT_SWEEP` | service | Overrides `conflict_sweep` (`off`, `report`, `enforce`) |
|
||||
| `PDFS_TERMINAL`, `TERMINAL` | app, prompt | Terminal for `open_with` rules with `"terminal": true` |
|
||||
| `LANGUAGE`, `LC_ALL`, `LC_MESSAGES`, `LANG` | app, tray, prompt | Interface language when `language` is unset |
|
||||
| `PDFS_LOCALEDIR` | app, tray, prompt | Load translations from another directory (development) |
|
||||
| `PDFS_STAND_IN_WIDGETS` | app | `1` uses the libadwaita 1.5 fallback widgets on newer systems (development) |
|
||||
|
||||
The acceptance suite has its own `PDFS_ACCEPTANCE_*` variables, listed in [TESTING.md](TESTING.md).
|
||||
|
||||
## The systemd service
|
||||
|
||||
`proton-drive.service` is a user unit. It restarts the service whenever it exits, restarts it when
|
||||
it stops answering for two minutes (`WatchdogSec=120`), and caps its memory at 6 GiB. To change
|
||||
the unit without editing the packaged file, use a drop-in:
|
||||
|
||||
```bash
|
||||
systemctl --user edit proton-drive.service
|
||||
```
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
Environment=RUST_LOG=pdfs_fuse=debug,info
|
||||
Environment=PDFS_CONFLICT_SWEEP=off
|
||||
```
|
||||
|
||||
Logs go to the journal: `journalctl --user -u proton-drive.service -f`.
|
||||
@@ -1,214 +1,164 @@
|
||||
# Development and Verification Status for 1.0
|
||||
# Development guide
|
||||
|
||||
This document summarizes the work still pending after the 1.0.0 release branch and records the most important recently completed changes. [`BUGS.md`](BUGS.md) is the authoritative issue ledger; [`TESTING.md`](TESTING.md) defines the acceptance commands.
|
||||
How to build, run, debug and release the client from source. Read
|
||||
[CONTRIBUTING.md](../CONTRIBUTING.md) for the review rules and [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
for how the pieces fit together.
|
||||
|
||||
---
|
||||
- [Setup](#setup)
|
||||
- [Repository layout](#repository-layout)
|
||||
- [Running from source](#running-from-source)
|
||||
- [Quality gates](#quality-gates)
|
||||
- [Debugging](#debugging)
|
||||
- [Common changes](#common-changes)
|
||||
- [Releasing](#releasing)
|
||||
|
||||
## 1. Core Feature Roadmap (Horizon Tasks)
|
||||
Consolidated from the historical feature roadmap; current bug status lives in [`BUGS.md`](BUGS.md).
|
||||
## Setup
|
||||
|
||||
* **Local Cache & Metadata Encryption at Rest (Priority 5 / Horizon 1)**: Encrypt the SQLite database `cache.db` on disk using SQLCipher (`rusqlite` feature `bundled-sqlcipher`), and encrypt raw content cache blocks (`content/blocks/` and `content/scratch/`) using a fast symmetric scheme like AES-GCM or ChaCha20-Poly1305. The encryption key should be derived from the OS Keyring. (The SDK entity cache in `sdk_cache.db` is already encrypted at rest — see *Persistent SDK Entity Cache* below — but `cache.db` and the content cache are not.)
|
||||
* **Active Bandwidth Throttling & Traffic Shaping (Horizon 1)**: Add speed governors for daemon uploads and downloads. Support configuration variables `max_upload_rate_kbps` and `max_download_rate_kbps` in `config.json` that can be dynamically adjusted over the Unix control socket.
|
||||
* **Interactive & Policy-Based Conflict Resolution (Horizon 1)**: Implement configurable conflict resolution policies (`rename-local`, `rename-remote`, `prefer-local`, `prefer-remote`, `interactive`). Add a "Conflicts" tab to the GUI to notify the user, block sync, and launch external visual diff tools (like Meld or KDiff3).
|
||||
* **Multi-Account / Multi-Profile Support (Horizon 3)**: Enable running multiple profiles concurrently. Support separate configuration namespaces (e.g. `profiles/<profile_name>/`), independent SQLite databases, distinct mount paths (e.g. `~/ProtonDrive/Personal` and `~/ProtonDrive/Work`), and multiple daemon processes.
|
||||
* **Custom FUSE Mount Options (Horizon 1)**: Expose custom mounting parameters in configuration (e.g. `ro` for read-only, `direct_io` to bypass kernel caching, `allow_other`). Auto-generate systemd user mount units upon sync folder configuration.
|
||||
* **Symbolic Link & Hard Link Virtualization (Horizon 2)**: Add symbolic link virtualization inside the metadata store (`NodeType::Symlink`) by intercepting `readlink(2)` and `symlink(2)`. Targets should sync remotely as small encrypted metadata placeholder files on Drive (`proton-vfs-symlink: <target>`).
|
||||
* **Desktop File Manager Integration (Horizon 2)**: Develop shell extension plugins for popular Linux file managers (Nautilus Python plugin, Dolphin Service Menus, Thunar custom actions) to offer context-menu shortcuts ("Pin", "Unpin", "Copy Share Link", "View Version History") and file overlay sync badges.
|
||||
* **Integrated Photo Gallery Enhancements (Horizon 2)**: Album *editing* — rename, cover photo, delete, and removing photos from an album — is still unported in the SDK (TS `updateAlbum` / `deleteAlbum` / `removePhotos`), as is adding a photo that lives on someone else's volume (TS `copyPhoto`). Album **creation** and adding own-volume photos landed with the Google Photos import. Still open: an auto-upload Pictures folder pipeline, and a GUI for creating albums by hand. Exif display, the date-based timeline scrubber and favorites are implemented.
|
||||
* **Google Photos import follow-ups**: the importer uploads photos one at a time — a large export would finish sooner with a bounded concurrent upload pool. Trash folders are recognized by a hardcoded list of localized names ([`takeout.rs`](../crates/pdfs-core/src/takeout.rs) `TRASH_FOLDERS`); a locale not on it imports its trash as ordinary photos. Motion-photo `.MP`/`.MV` sidecar videos are ignored rather than uploaded as related photos. Live validation against a real export is pending.
|
||||
* **Random-Access Media Streaming seek support (Horizon 2)**: Intercept out-of-order sparse reads (player seeks) in `pdfs-fuse` and prioritize downloading blocks around the seek offset, aborting/postponing sequential pre-fetches.
|
||||
* **Dynamic Sync Dashboard & Queue Visualization (Horizon 2)**: GUI real-time transfer list (progress bars, speed, ETA), sync history feed, storage usage breakdown, and global pause/resume sync controls.
|
||||
* **Interactive Terminal UI (TUI) Mode (Horizon 3)**: Implement `pdfs tui` using the `ratatui` crate to show transfer speed, sync status, active transfer queue with progress bars, and scrolling daemon logs.
|
||||
* **Shutdown Safety & Write Queue Visibility (Horizon 1 / P4)**: Register systemd inhibitor locks in `pdfs-fuse` when there are outstanding staging writes to prevent shutdown or sleep, pop up warnings when exiting the GUI, and build a blocking `pdfs sync flush` command.
|
||||
* **Block-Level Deduplication (Horizon 3)**: Transition to a Content-Addressable block cache where cached blocks are stored on disk by their SHA-256 hash. Map logical ranges in the database: `(node_uid, block_idx) -> block_hash` to avoid storing or downloading identical blocks.
|
||||
* **Pre-emptive Sync Debouncing & File System Events (Horizon 3)**: Group filesystem write events on paths and delay sync queue insertion until a quiet period has elapsed (e.g. 5 seconds of inactivity) to prevent thrashing the Proton API.
|
||||
You need Rust 1.96 or newer and the native development packages listed under
|
||||
[Build dependencies](INSTALL.md#build-dependencies) for your distribution. Then:
|
||||
|
||||
---
|
||||
```bash
|
||||
git clone https://github.com/narrrl/proton-drive-linux.git
|
||||
cd proton-drive-linux
|
||||
cargo build --workspace
|
||||
```
|
||||
|
||||
## 2. Performance, Correctness & Robustness Items
|
||||
Consolidated from the performance and media-streaming audit notes.
|
||||
The GUI builds against libadwaita 1.5, the version in Ubuntu 24.04, and uses newer widgets at run
|
||||
time when the installed library has them (`crates/pdfs-gui/src/compat.rs`).
|
||||
|
||||
* **B6. Debounce the LRU access touch**: Keep last-touch times in memory and flush them in batches (debounce window of 30-60 seconds) to avoid performing a SQLite `UPDATE` to the `cache_entries` table on every cache hit.
|
||||
* **E12. Client in-flight cap shape**: Address the lock discrepancy between `MAX_CONCURRENT_BLOCK_DOWNLOADS = 10` (per-file) and `DEFAULT_MAX_INFLIGHT_BLOCKS = 12` (global client-wide). Either raise the global cap, lower the per-file window, or dynamically calculate the window to prevent single files from starving concurrent transfers.
|
||||
* **Pipeline `enumerate_nodes_detail` (P2.8 / 8)**: Introduce pipelined node enumeration to speed up directory listings.
|
||||
* **Local ancestor-chain walk (P3.4 / 4)**: Optimize recursive path lookups where full batching is impossible.
|
||||
* **Name Search for Photos/Videos**: Add search by photo/video name (deferred from Phase 3 gallery implementation).
|
||||
* **Sequential-Read Prefetch for Media Streaming**: Implement sequential read prefetch for media files to avoid stalls when buffering.
|
||||
* **Minor sweep items (Phase F)**:
|
||||
* **F1. `StreamRing.tags` Leak**: Clean up tag entries in `StreamRing.tags` when blocks are evicted. Currently, it only shrinks on `drop_node` (revision mismatch), leading to tag accumulation.
|
||||
* **F2. Non-saturating bytes subtraction**: Use `saturating_sub` in `lib.rs:382` (`self.bytes -= dropped.len()`) to prevent release mode panics/wrapping if accounting drifts.
|
||||
* **F3. Short block yields a short read / EOF**: Address the kernel EOF interpretation on short block reads. Either pad the block or fail loudly rather than silently serving short data.
|
||||
* **F4. `stream_readahead` check-then-spawn race**: Prevent duplicate concurrent readahead tasks for the same block. Check-then-spawn is currently racey.
|
||||
* **F5. `refresh_blocks` permit lock (SDK)**: Ensure URL refreshing (`refresh_blocks`) does not hold an in-flight block permit, preventing expired URLs from pinning client permits.
|
||||
* **F7. Document temporary cache file safety**: Add explanatory comment to `cache.rs:362` explaining why `with_extension("tmp")` is safe there but not in `store_thumbnail`.
|
||||
## Repository layout
|
||||
|
||||
---
|
||||
| Path | Contents |
|
||||
|---|---|
|
||||
| `crates/pdfs-core` | Shared library: authentication and keyring (`auth`), configuration (`config`), SQLite schema and queries (`db/`), content cache (`cache`), control protocol (`control`), search scoring, sync ignore rules, Google Takeout parsing, machine profile |
|
||||
| `crates/pdfs-fuse` | The daemon: FUSE filesystem, read path (`reads`), write queue (`queue`, `drain`, `upload`), sync engine (`sync/`), remote events (`background`), push events for front ends (`events`), control handlers (`control`), photos, devices, supervisor |
|
||||
| `crates/pdfs-cli` | The `pdfs` binary |
|
||||
| `crates/pdfs-gui` | `pdfs-app`, `pdfs-tray` and `pdfs-prompt`. Pages in `src/app/pages/`, shared widgets in `src/app/widgets/`, CSS and icons in `resources/` |
|
||||
| `po/` | Translation template, catalogs and the scripts that maintain them |
|
||||
| `packaging/` | systemd unit, desktop files, icon, PKGBUILD, RPM spec, AUR recipes |
|
||||
| `scripts/` | FUSE acceptance suite, map-data generators |
|
||||
| `docs/` | This documentation |
|
||||
|
||||
## 3. Open & Unverified Bugs
|
||||
Derived from: [`BUGS.md`](BUGS.md)
|
||||
The GUI talks to the daemon only through the control socket. Anything a front end needs that the
|
||||
daemon does not offer yet is a new request in `pdfs_core::control`, not a database read.
|
||||
|
||||
* **B2. Unattributed Trash Origin**: Investigate why deleted/moved files are occasionally sent to trash instead of vanishing on older rename operations.
|
||||
* **B10. GLib Critical Warning on HUD Close**: Assertion failure `g_list_store_remove: assertion '!g_sequence_iter_is_end (it)' failed` when prompt window closes during active launch (`xdg-open`). Needs investigation under `G_DEBUG=fatal-criticals`.
|
||||
* **B15. Empty-but-listed folders and duplicate UIDs**: Investigate folder-identity issues (e.g. duplicate remote folders like `Music` with different UIDs) and how folders obtain `listed = 1` in the database without matching child rows.
|
||||
* **B8 (CAPTCHA sign-in bridge)**: Verify a real gated sign-in using Webkit CAPTCHA completion.
|
||||
* **Draft Revision Upload loop**: Verify SDK-side deletion and retry logic when a conflicting draft revision exists under the same client UID.
|
||||
* **Optimistic Size Loss on Restart**: Verify that pending uploads retain their optimistic sizes across a daemon restart.
|
||||
* **B48 — stale listing after unlink**: Session tombstones and authoritative empty listings are implemented; run focused live verification against eventually consistent remote enumeration.
|
||||
* **B49–B55 — data-safety fixes**: Conflict preservation, orphan/staging durability, transactional supersession, one-entry wipe protection, and incomplete-scan handling have regression coverage but still require the fault, power-loss, and live mode-matrix cases recorded in `BUGS.md`.
|
||||
* **B56–B60 — fail-closed state and IPC fixes**: Future schemas, event cursor advancement, socket limits, private-directory validation, and atomic config saves are implemented. Malformed-schema, database-full, connection-flood, wrong-owner, and concurrent/faulted-save verification remains.
|
||||
* **B61 — credential rotation persistence**: Surface and retry keyring persistence failures so a rotated single-use refresh token cannot be lost on restart.
|
||||
* **B62–B65 — release assurance**: Clean-install package verification, complete version/protocol identity, recovery sign-off, advisory/license policy, SBOM, provenance, checksums, and signatures remain.
|
||||
* **B66–B67 — lifecycle and recovery**: Add queue flush/shutdown protection and explicit device adoption during fresh-state restore.
|
||||
* **B68/B78 — profile backup**: the destination is fixed and verified live — `profile.json` now lives in `.proton-drive-linux/` under the device root, since the API refuses file creation at the root itself. What remains from B68 is making backup *health* visible (today a failure is a `WARN` in the log and nothing in the UI) and re-running the replacement-machine restore end to end.
|
||||
* **B79 — cross-mount permission classification**: fixed and verified live; the standing guard is the `regression B79` acceptance case. The second-account cases it neighbours (`regression B34`) still cannot run — see below.
|
||||
## Running from source
|
||||
|
||||
---
|
||||
The packaged service and a daemon run from source cannot share the same state directory. Stop the
|
||||
service before you run your own build:
|
||||
|
||||
## 4. Testing, Verification & Disaster Recovery Drills
|
||||
Derived from: [`TESTING.md`](TESTING.md) and [`RECOVERY.md`](RECOVERY.md)
|
||||
```bash
|
||||
systemctl --user stop proton-drive.service
|
||||
RUST_LOG=pdfs_fuse=debug,info cargo run -p pdfs-cli -- daemon
|
||||
```
|
||||
|
||||
* **IPC Memory and Performance Stress Tests (Phase 3)**: Verify memory allocations do not balloon when sending large `UploadPhoto` JSON payloads. Test control socket timeout behaviors under long-running block operations.
|
||||
* **Fault Injection and Resiliency Tests (Phase 4)**:
|
||||
* **Network degradation**: Verify that interrupted uploads leave readable staging buffers and result in clean backoff retries.
|
||||
* **Disk full (ENOSPC)**: Verify that write staging and gap-filling report correct errors to FUSE and preserve staged work.
|
||||
* **Concurrent reader starvation**: Test that parallel sequential reads do not freeze directory lookups or metadata handlers.
|
||||
* **Disaster Recovery Drill (P5 / Phase 5)**: Execute a complete manual disaster recovery test:
|
||||
1. Register a device.
|
||||
2. Sync a folder and upload data.
|
||||
3. Wipe the local state directory and SQLite cache.
|
||||
4. Restart the daemon.
|
||||
5. Run `pdfs sync restore` and assert the recovered filesystem matches the source byte-for-byte.
|
||||
* **Second-account sharing run (mount-architecture.md P7)**: The only outstanding verification for the mount/sharing work. One account covers `context_share_id`, the shared-with-me listing, sharing from device folders and the Locations page — all confirmed. A **second account** is required for the shared-tree rendering under a foreign volume, `accept_invitation`, `update_member_role`, the `regression B34` viewer cases (which skip cleanly today), and above all an **editor-role write into a foreign share**, the only thing that validates `membership_address_for`.
|
||||
* **Managed live mode matrix**: Before release, run `scripts/fuse-acceptance.sh --managed-live <empty-a> <empty-b>` with `PDFS_ACCEPTANCE_ONLY` unset. It validates on-demand/on-demand, on-demand/mirror, mirror/on-demand, and mirror/mirror transitions.
|
||||
* **Crash-consistency matrix**: Inject failure after scratch/sidecar sync, each staging rename, directory sync, pending-op insert/commit, and source deletion; prove that a complete discoverable copy and either the old or new queue record always survives.
|
||||
In another terminal:
|
||||
|
||||
---
|
||||
```bash
|
||||
cargo run -p pdfs-cli -- status
|
||||
po/build.sh target/locale # compile translations once
|
||||
PDFS_LOCALEDIR=target/locale cargo run -p pdfs-gui --bin pdfs-app
|
||||
cargo run -p pdfs-gui --bin pdfs-tray
|
||||
cargo run -p pdfs-gui --bin pdfs-prompt -- --fzf
|
||||
```
|
||||
|
||||
## 5. Fixed & Resolved Bugs
|
||||
Derived from: [`BUGS.md`](BUGS.md)
|
||||
To keep the systemd unit but point it at your build, see
|
||||
[INSTALL.md](INSTALL.md#using-a-binary-outside-usrbin).
|
||||
|
||||
### Completely Fixed & Verified
|
||||
* **B1 — FUSE rename loses the file (data loss)**: In `rename`, the node's database row was deleted while only updating the in-memory state, causing the file to vanish on next listing sync. Fixed by correctly calling `st.invalidate_listing(newparent)` to refresh SQLite cache states.
|
||||
* **B4 — `invalidate_listing` silently skipped non-resident folders**: Folders not loaded in memory were being skipped during listing invalidation, leaving stale cache flags in the DB. Fixed by removing the residency check guard.
|
||||
* **B5 — `ls -l` costs a network round trip per file (thumbnail xattr probes)**: File listings triggered blocking network checks for thumbnails on unsupported files. Fixed by caching negative thumbnail results and restricting xattr advertisement to supported image/media files.
|
||||
* **B6 — Daemon sets no file modes: `control.sock` is an unguarded authority**: The Unix control socket was created without restricting file permissions, exposing daemon controls to other local users. Fixed by applying proper creation permission masks.
|
||||
* **B7 — renamed directory reads as missing until the entry TTL expires**: Kernel cache maps remained stale after a FUSE directory re-anchor. Fixed by proactively sending entry invalidation notifications to the kernel.
|
||||
* **B9 — Enter did not open the selected result in the launcher**: Hooked up keyboard execution (Enter key) in `pdfs-prompt` search HUD to launch the selected item using `xdg-open`.
|
||||
* **B11 — a file moved while its create is still queued reads as empty**: Reconciling a local rename on a file that had not finished uploading yet uploaded empty blocks. Fixed by carrying over and staging the correct logical file size.
|
||||
* **B12 — cold enumeration is slow per entry, and goes superlinear past ~500**: S2K decryption and thumbnail resolution loops were serial. Fixed by parallelizing key derivations and caching metadata.
|
||||
* **B13 — `rename` over an existing destination fails instead of replacing it**: Fixed POSIX rename target overwrite behavior to replace existing destinations.
|
||||
* **B14 — provisional (ciphertext) sizes make rsync read short and abort the file**: Adjusted size reporting to align logical and ciphertext sizing, preventing premature EOF.
|
||||
* **B45 — truncate over a queued rewrite**: Path-based and handle-based truncate now compose over the complete pending blob; shrink, growth, and sparse I/O are live-verified.
|
||||
* **B46 — combined cross-directory move and rename**: The desired end state is durably queued and reconciles partially completed remote operations; replacement and combined cases are live-verified.
|
||||
* **B47 — overlong/invalid names**: All name-taking callbacks share a validator for `NAME_MAX`, reserved components, and UTF-8; managed-live verification passed.
|
||||
> [!CAUTION]
|
||||
> Develop against a test account. The acceptance suite and a buggy daemon can delete or rewrite
|
||||
> files in Proton Drive.
|
||||
|
||||
### Implemented in 1.0 (Further Fault Verification Pending)
|
||||
> [!TIP]
|
||||
> A daemon stopped in a debugger or deadlocked freezes every process that touches the mount,
|
||||
> including your shell if its prompt reads the current directory. Keep your shell outside
|
||||
> `~/ProtonDrive`, prefix commands that touch it with `timeout`, and unmount a dead mount with
|
||||
> `fusermount3 -uz ~/ProtonDrive`.
|
||||
|
||||
* **Unified search and resident prompt**: `SearchV2` combines Drive and local lookup with shared relevance scoring and content/source filters. The prompt reuses its window and opens streamable media through FUSE.
|
||||
* **Durable write publication**: Scratch sidecars and staged blobs use synced temporary files, atomic renames, and directory synchronization; orphan rescue retains its source on failure.
|
||||
* **Transactional pending operations**: Superseding a queued operation cannot delete the old durable record unless the replacement commits.
|
||||
* **Safer mirror reconciliation**: Conflict-copy failure aborts publication, incomplete scans are non-destructive, and total-wipe protection includes one-entry baselines.
|
||||
* **Bounded IPC**: Control requests are limited to 1 MiB and 10 seconds, with at most 64 active handlers.
|
||||
* **Private local state**: Sensitive directories require correct ownership, real-directory identity, and `0700`; sockets use `0600`; config saves are atomic and malformed input is preserved.
|
||||
* **Release gates**: Tag/workspace/package versions are checked and formatting, lint, tests, and offline FUSE acceptance run before packaging.
|
||||
## Quality gates
|
||||
|
||||
### Code Fixed / Implemented (Pending Verification)
|
||||
* **B8 — no way to complete a CAPTCHA, so a gated sign-in is unrecoverable**: Implemented a Webkit-based bridge to prompt the user to resolve interactive CAPTCHAs during gated logins. (Needs verification on a real gate request).
|
||||
CI (`.github/workflows/ci.yml`) runs these on every push to `main` and every pull request. Run them before you
|
||||
push:
|
||||
|
||||
### Closed / Not a Bug
|
||||
* **B3 — Activity log timestamps written in seconds, read as milliseconds**: Closed as investigator error (timestamps are consistent).
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace --locked
|
||||
scripts/fuse-acceptance.sh --offline-only
|
||||
```
|
||||
|
||||
---
|
||||
For changes to the FUSE layer, the write queue or the sync engine, also run the live acceptance
|
||||
cases described in [TESTING.md](TESTING.md).
|
||||
|
||||
## 6. Recently Implemented Features
|
||||
For changes to GUI strings, check that every catalog is complete:
|
||||
|
||||
### File Version History (Implemented)
|
||||
**Files**: [`revisions.rs`](../crates/pdfs-fuse/src/revisions.rs), [`control.rs`](../crates/pdfs-core/src/control.rs), [`versions_dialog.rs`](../crates/pdfs-gui/src/app/widgets/versions_dialog.rs)
|
||||
```bash
|
||||
for l in $(cat po/LINGUAS); do msgfmt --check --statistics -o /dev/null po/$l.po; done
|
||||
```
|
||||
|
||||
Proton Drive keeps every revision a client committed; the daemon only ever addressed the active one, so a file overwritten by a sync pass could be recovered only from whatever the local `recovery/` directory happened to hold.
|
||||
## Debugging
|
||||
|
||||
The control protocol gained `ListRevisions` / `RestoreRevision` / `DeleteRevision` / `SaveRevisionAs` (each with a `…ByUid` twin for nodes the primary mount cannot name), the CLI gained `pdfs versions list|restore|save|rm`, and the browser's details pane gained a **Versions** button opening a per-file dialog.
|
||||
| Tool | Use |
|
||||
|---|---|
|
||||
| `RUST_LOG` | Log filter for every binary, for example `RUST_LOG=pdfs_fuse::sync=trace,info` |
|
||||
| `pdfs diagnose` | Static checks: directories, FUSE, keyring, socket, database integrity. Works without the daemon |
|
||||
| `pdfs diagnostics` | Live state of a running daemon: each worker's current job and its age, queue depth, control requests in flight, memory |
|
||||
| `pdfs cache inspect --deep` | Database size, row counts and SQLite `integrity_check` |
|
||||
| `pdfs --json …` | Raw protocol payloads for query commands |
|
||||
| `GTK_DEBUG=interactive` | The GTK inspector for `pdfs-app` |
|
||||
| `PDFS_STAND_IN_WIDGETS=1` | Force the libadwaita 1.5 fallback widgets on a newer system |
|
||||
| `G_DEBUG=fatal-criticals` | Turn GLib criticals into crashes to get a backtrace |
|
||||
|
||||
Three properties that shape the code:
|
||||
The databases are ordinary SQLite files. Open a copy, not the live file:
|
||||
|
||||
- **A restore is server-side and asynchronous.** No content crosses the wire and nothing enters the drain queue; the server answers 202 and swaps the active revision in the background, so the daemon evicts the file's cached blocks and open readers rather than describing the new state, and the UI wording promises the request, not the result.
|
||||
- **The active revision cannot be deleted.** `Core::delete_revision_for_uid` checks that locally so the user gets a sentence rather than an API code, and the dialog gives the current row no delete button.
|
||||
- **Saving a version never overwrites.** `SaveRevisionAs` refuses an existing destination and removes a partial file if the download fails — a half-written export looks identical to a good one to every tool that opens it afterwards.
|
||||
```bash
|
||||
cp ~/.local/state/proton-drive-linux/cache.db /tmp/cache.db
|
||||
sqlite3 /tmp/cache.db '.tables'
|
||||
```
|
||||
|
||||
### Photo Favorites (Implemented)
|
||||
**Files**: [`photos.rs`](../crates/pdfs-fuse/src/photos.rs), [`photo_viewer.rs`](../crates/pdfs-gui/src/app/pages/photo_viewer.rs)
|
||||
## Common changes
|
||||
|
||||
The gallery can mark and filter favorites, through the SDK's photo-tag API (`update_photos`). Schema **v20** adds `photos.favorite`; the flag is learned in the timeline enrichment pass that already resolves each photo's name and media type, and is kept across a refresh that could not resolve a photo — the same learned-and-kept rule as `media_type`. The lightbox carries a star toggle, the Photos header a favorites filter, and the CLI has `pdfs favorite <uid> [--remove]` plus `pdfs photos --favorites`.
|
||||
**A new control request.** Add the variant to `Request` (and a `Response` if it returns data) in
|
||||
`crates/pdfs-core/src/control.rs`, handle it in `crates/pdfs-fuse/src/control.rs`, and list the
|
||||
`Topic`s it changes in the same file so subscribed front ends refresh. A daemon from the previous
|
||||
release may still be running when a new front end starts, so treat an error reply to a new
|
||||
request as "not supported".
|
||||
|
||||
Favouriting a photo that is not on this account's own photos volume (shared with us, or album-only) needs it re-encrypted for our timeline root, which the SDK does not implement; the daemon surfaces that as an error rather than silently doing nothing.
|
||||
**A schema change.** Migrations are forward-only. Add a new step to
|
||||
`crates/pdfs-core/src/db/migrations.rs` and bump `SCHEMA_VERSION`; never edit a shipped step. Add
|
||||
a test in `crates/pdfs-core/src/db/tests.rs` that migrates from the previous version.
|
||||
|
||||
### Justified Gallery, Gallery Delete (Implemented)
|
||||
**Files**: [`photos.rs`](../crates/pdfs-gui/src/app/pages/photos.rs), [`photo_viewer.rs`](../crates/pdfs-gui/src/app/pages/photo_viewer.rs), [`photos.rs`](../crates/pdfs-fuse/src/photos.rs), [`background.rs`](../crates/pdfs-fuse/src/background.rs)
|
||||
**A GUI string.** Wrap it in the helpers from `crates/pdfs-gui/src/i18n.rs`, add any new source
|
||||
file to `po/POTFILES.in`, run `po/update.sh`, and translate the new entries in every catalog. See
|
||||
[TRANSLATING.md](TRANSLATING.md). The CLI and daemon messages stay in English.
|
||||
|
||||
The gallery lays each day out in justified rows (`justify_rows` / `plan_rows` / `fit_row`): photos are taken in capture order until their summed aspect ratio no longer fits the target row height, and the row is then scaled so it ends exactly on the content width. A tile is therefore its own photo's shape, and `ContentFit::Contain` has nothing left to crop. The last row of a day is left at the target height rather than stretched, because a day holding two photos is a short day, not a layout fault.
|
||||
**A setting.** Add the field to `AppConfig` in `crates/pdfs-core/src/config.rs` with
|
||||
`#[serde(default)]`, so older files still load, and document it in
|
||||
[CONFIGURATION.md](CONFIGURATION.md).
|
||||
|
||||
- **Ratios that are not known yet.** `PhotoItem::ratio` is persisted, but a photo that has never been decoded has none. Those are laid out square and remembered in `assumed_ratios`; when a decode proves the real shape the day re-flows on the existing debounce timer, so a screenful of decodes costs one re-flow rather than one per photo. Ratios are clamped to 0.4–3.0 so a single panorama cannot decide what a row looks like.
|
||||
- **Thumbnails survive recycling.** A reply for a tile that has already been re-bound still lands in the texture cache, which is an LRU of 1500 textures keyed by uid, and the rows just outside the realised range are prefetched in the scroll direction.
|
||||
- **Delete is uid-addressed.** The photos volume is not in the FUSE mount, so the path-based `Request::Delete` cannot reach it. `Request::TrashNodes { uids }` trashes through the SDK and answers `Response::Trashed { trashed, failed }`; the daemon then drops those rows with `Db::photos_delete` (album membership included) so the gallery does not wait for a timeline refresh. The GUI removes the tiles optimistically with an Undo toast and puts back anything the server refused.
|
||||
- **Remote deletions arrive on their own.** The Drive volume and the photos volume have separate event streams, so `run_photos_event_sync` polls the photos volume with its own cursor (`photos_event_cursor` in the state table). Trash and delete events remove rows; anything else only invalidates freshness, because there is no inode space to converge on this volume. Together with a 60 s `TIMELINE_TTL` and a `RefreshScope::Photos` that now awaits the refresh, a photo deleted on a phone leaves the grid in about ten seconds.
|
||||
## Releasing
|
||||
|
||||
### RAW + JPEG Grouping (Implemented)
|
||||
**Files**: [`photos.rs`](../crates/pdfs-core/src/db/photos.rs), [`migrations.rs`](../crates/pdfs-core/src/db/migrations.rs), [`photos.rs`](../crates/pdfs-fuse/src/photos.rs), [`photo_viewer.rs`](../crates/pdfs-gui/src/app/pages/photo_viewer.rs)
|
||||
Releases are cut from `main` by pushing a `v*` tag. `.github/workflows/release.yml` then:
|
||||
|
||||
Schema **v30** adds `content_hash`, `main_uid` and an indexed `group_key` to `photos`. The first two come from the server's `PhotoProperties` and are filled by the enrichment pass in `refresh_timeline` that already resolves each photo's name, media type and favorite tag; `group_key` is computed in `photos_replace`, where the whole timeline is in hand.
|
||||
1. checks that the tag, the workspace version in `Cargo.toml` and `pkgver` in
|
||||
`packaging/PKGBUILD` agree;
|
||||
2. runs the quality gates;
|
||||
3. builds the release binaries and packages a `.tar.gz`, a `.deb` and a GPG-signed `.rpm`;
|
||||
4. publishes them as a GitHub release;
|
||||
5. calls `.github/workflows/aur.yml`, which updates and pushes the AUR recipes and commits them
|
||||
back to `main`.
|
||||
|
||||
- **Precedence.** The server relation first (`main_photo_uid` and `related_photo_uids`, read from whichever end resolves — the two can land in different enrichment chunks); then same capture day, same case-insensitive name stem, and one member raw while the other is not; otherwise the photo is its own group. The union-find pass is over the timeline in memory, so it costs one pass per refresh rather than a query per photo.
|
||||
- **Representative.** The non-raw member when there is one — a JPEG decodes in milliseconds and is what the person expects to see. `group_key` holds that member's uid, so "is this the tile" is `uid = group_key`, an index scan.
|
||||
- **What is grouped and what is not.** `photos_page`, `photos_months` and `photos_counts` count groups, except on the Raw tab, which lists files. Album pages are never grouped: an album is a list someone made. The relation is learned-and-kept like `media_type`, so a refresh that could not resolve a node does not break a group up.
|
||||
- **Deleting.** `Core::trash_photos` expands each uid to its group before trashing, because the grid shows the shot as one tile — leaving the RAW behind would put the photo back on the Raw tab and nowhere else. The GUI's confirmation names the file count.
|
||||
- **The other files.** `Request::PhotoGroup { uid }` answers with the group's members as ordinary `PhotoItem`s, which is what the lightbox's switch steps through.
|
||||
To prepare a release:
|
||||
|
||||
### Persistent SDK Entity Cache (Implemented)
|
||||
**Files**: [`sdkcache.rs`](../crates/pdfs-core/src/sdkcache.rs), [`auth.rs`](../crates/pdfs-core/src/auth.rs), [`background.rs`](../crates/pdfs-fuse/src/background.rs)
|
||||
```bash
|
||||
# 1. Bump the version in Cargo.toml and packaging/PKGBUILD, then refresh the lock file.
|
||||
cargo update --workspace
|
||||
# 2. Move the [Unreleased] notes in docs/CHANGELOG.md under the new version.
|
||||
# 3. Commit, tag and push.
|
||||
git commit -am "chore(release): X.Y.Z"
|
||||
git tag vX.Y.Z
|
||||
git push origin main vX.Y.Z
|
||||
```
|
||||
|
||||
The SDK's Drive entity cache (decrypted node metadata: name, size, parent, signing share) defaulted to memory, so every daemon restart re-fetched and re-decrypted the tree the previous run had already walked. It is now backed by SQLite.
|
||||
|
||||
- **Its own file** (`sdk_cache.db` in the state directory), not `cache.db`: the daemon's `Db` is one `Mutex<Connection>` shared by every FUSE thread and the control socket, and this traffic is frequent, small and entirely reconstructible. A separate file also means no schema migration — the store can be deleted at any time.
|
||||
- **Encrypted at rest** by wrapping it in the SDK's `EncryptedCacheRepository`, keyed by the mailbox password. A password change reads as a cold cache (the SDK treats an undecryptable entry as a miss and clears the store), not as an error.
|
||||
- **Staleness** is closed by the event loop, which already replays from a persisted cursor and calls `invalidate_caches_for_event` for every event — including those raised while the daemon was down. The one case with no trail is a *seeded* cursor (first-ever mount, or a lost cursor), where the seed path now clears the store instead of trusting it. `auth::logout` removes the file.
|
||||
|
||||
Needed a small additive SDK change (`ProtonDriveClient::with_entity_repository`, 0.5.1): `with_entity_cache` is a constructor and so could not be combined with `with_key_salts`, which the daemon requires.
|
||||
|
||||
### Per-node Batch Outcomes (Implemented)
|
||||
**File**: [`batch.rs`](../crates/pdfs-core/src/batch.rs)
|
||||
|
||||
SDK 0.5.0 made `trash_nodes` / `restore_nodes` / `delete_nodes` report one outcome per node instead of failing the whole call (mirroring upstream's streamed `NodeActionResult`). `pdfs_core::batch::into_unit` collapses the single-node calls back to a `Result`; `batch::split` handles a collected batch.
|
||||
|
||||
A restore is expanded before it is sent (`expand_restore`, `pdfs-fuse/src/lib.rs`): the persisted trash listing carries each row's `parent_uid` (schema 29), so a restore takes the connected piece of the trashed tree — every trashed descendant of what was asked for, and every trashed ancestor above it — and sends it shallowest wave first, because the server cannot put a node back under a parent that is still trashed. A uid the listing does not know about (it is materialised in chunks, and can be stale) is restored on its own rather than dropped.
|
||||
|
||||
The trash view's restore and permanent-delete use the SDK's **streaming** variants (`restore_nodes_streaming` / `delete_nodes_streaming`) and apply local state per node as each batch lands rather than after the last one. For a permanent delete — which is irreversible — that means a daemon interrupted mid-batch has forgotten exactly the nodes the server destroyed, no more and no fewer. Both report per-node failures and count only what actually succeeded; only a batch where nothing succeeded is an error.
|
||||
|
||||
|
||||
### Open-for-Write Deferral for Mirror Sync (Implemented)
|
||||
**File**: [`sync.rs`](../crates/pdfs-fuse/src/sync.rs)
|
||||
|
||||
The mirror folder sync engine now defers uploading any file that is currently held open for writing by another process, matching the guarantee the FUSE mount path already provides (where uploads are deferred until `close(fd)`).
|
||||
|
||||
**Problem**: Previously, the mirror sync path relied solely on a 2-second trailing-edge debounce (with a 30-second ceiling) to avoid uploading files mid-write. This was insufficient for slow continuous writers (e.g., database dumps, large exports, or editors that keep files open for extended periods), which could have their incomplete state uploaded as a real revision.
|
||||
|
||||
**Solution**: Before each reconcile pass, the sync engine scans `/proc/*/fd` once to build a set of canonical paths within the sync root that any process holds open for writing (`O_WRONLY` or `O_RDWR`). Files in this set are:
|
||||
|
||||
- **Kept in the local walk** — so they are not misclassified as deletions
|
||||
- **Treated as unchanged** — so no upload is queued for them
|
||||
- **Counted as `deferred`** — so the activity summary reports them (e.g., "3 uploaded, 1 deferred (open for write)")
|
||||
- **Picked up on the next pass** — after the writer has closed the file
|
||||
|
||||
**How it works**:
|
||||
1. `open_for_write_set(root)` reads `/proc/*/fd` → `readlink` for each fd → prefix-checks against the sync root → reads `/proc/<pid>/fdinfo/<n>` to check the `flags:` line's low two bits (O_ACCMODE)
|
||||
2. `walk_local` sets `LocalItem.open_for_write = true` for matching files
|
||||
3. Both `do_reconcile` and `push_pass` skip upload classification for flagged files
|
||||
4. `Outcome.deferred` tracks the count for the activity summary
|
||||
|
||||
**Performance**: The `/proc` scan completes in low single-digit milliseconds on a typical desktop. Only fds whose `readlink` target falls under the sync root incur the `fdinfo` read.
|
||||
|
||||
**Tests added**: 8 unit tests covering `is_write_mode` parsing of various fdinfo flag combinations, and `Outcome` summary formatting with deferred counts.
|
||||
|
||||
| Sync Path | Open-for-write protection | Mechanism |
|
||||
|---|---|---|
|
||||
| FUSE mount | ✅ Perfect | Upload deferred until `close(fd)` |
|
||||
| Mirror folders | ✅ Perfect | `/proc/*/fd` scan per reconcile pass |
|
||||
Before a release that touches the filesystem or sync, run the managed live mode matrix from
|
||||
[TESTING.md](TESTING.md). Open release-assurance items are listed in [ROADMAP.md](ROADMAP.md).
|
||||
@@ -0,0 +1,251 @@
|
||||
# Installation
|
||||
|
||||
This page covers installing Proton Drive for Linux from a package or from source, the first
|
||||
sign-in, running without a desktop, and removing the client again.
|
||||
|
||||
- [Requirements](#requirements)
|
||||
- [Install a package](#install-a-package)
|
||||
- [Build from source](#build-from-source)
|
||||
- [First run](#first-run)
|
||||
- [Headless and server use](#headless-and-server-use)
|
||||
- [Updating](#updating)
|
||||
- [Uninstalling](#uninstalling)
|
||||
|
||||
## Requirements
|
||||
|
||||
| | Minimum |
|
||||
|---|---|
|
||||
| Architecture | x86_64 (release packages); other architectures build from source |
|
||||
| Kernel | FUSE 3 (`fuse3` package, `/dev/fuse` available to your user) |
|
||||
| GTK / libadwaita | GTK 4.12, libadwaita 1.5 (1.8 or newer gets the newest widgets) |
|
||||
| WebKitGTK | 6.0 — used only for the CAPTCHA page during sign-in |
|
||||
| Secret Service | GNOME Keyring, KWallet, KeePassXC, or another provider of `org.freedesktop.secrets` |
|
||||
| Rust (source builds) | 1.96 |
|
||||
|
||||
The session is stored only in the Secret Service. Without a running provider, sign-in succeeds but
|
||||
cannot be saved, and the service cannot start unattended.
|
||||
|
||||
Optional runtime extras:
|
||||
|
||||
| Package | Adds |
|
||||
|---|---|
|
||||
| `exiftool` (`perl-image-exiftool`, `libimage-exiftool-perl`) | Thumbnails for camera RAW files |
|
||||
| `ffmpeg` | Video thumbnails |
|
||||
| `iso-codes` | Country names in your language on the Places map |
|
||||
| `xdg-utils` | "Open Folder" in the tray, and the default file opener |
|
||||
| `fzf` plus a terminal (foot, kitty, alacritty, …) | `pdfs-prompt --fzf`, the search-as-you-type launcher |
|
||||
| AppIndicator extension (GNOME only) | The tray icon. KDE Plasma and most other desktops show it natively |
|
||||
|
||||
## Install a package
|
||||
|
||||
Every [release](https://github.com/narrrl/proton-drive-linux/releases/latest) ships a `.deb`, a
|
||||
signed `.rpm` and a `.tar.gz` of the raw binaries. All packages install the same files:
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `/usr/bin/pdfs` | Command-line client and background service |
|
||||
| `/usr/bin/pdfs-app` | Desktop app |
|
||||
| `/usr/bin/pdfs-tray` | Tray icon |
|
||||
| `/usr/bin/pdfs-prompt` | Search launcher |
|
||||
| `/usr/lib/systemd/user/proton-drive.service` | User service that keeps the drive mounted |
|
||||
| `/etc/xdg/autostart/io.narl.proton-drive-linux-tray.desktop` | Starts the tray at login |
|
||||
| `/usr/share/locale/*/LC_MESSAGES/pdfs.mo` | Translations |
|
||||
|
||||
### Debian and Ubuntu (24.04 or newer)
|
||||
|
||||
```bash
|
||||
sudo apt install ./proton-drive-linux_*_amd64.deb
|
||||
```
|
||||
|
||||
### Fedora (44 or newer)
|
||||
|
||||
```bash
|
||||
sudo dnf install ./proton-drive-linux-*.x86_64.rpm
|
||||
```
|
||||
|
||||
For a Fedora package built from your own checkout, see
|
||||
[Build an RPM locally](#build-an-rpm-locally).
|
||||
|
||||
### Arch Linux (AUR)
|
||||
|
||||
Three 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) | Latest tagged release, built from source |
|
||||
| [`proton-drive-for-linux-bin`](https://aur.archlinux.org/packages/proton-drive-for-linux-bin) | Latest tagged release, prebuilt binaries |
|
||||
| [`proton-drive-for-linux-git`](https://aur.archlinux.org/packages/proton-drive-for-linux-git) | `main`, built from git |
|
||||
|
||||
```bash
|
||||
paru -S proton-drive-for-linux # or yay, or any other AUR helper
|
||||
```
|
||||
|
||||
The recipes live in [`packaging/aur/`](../packaging/aur/README.md).
|
||||
|
||||
### Other distributions
|
||||
|
||||
Unpack the `.tar.gz` into a directory on your `PATH`, then install the service unit as shown in
|
||||
[Using a binary outside `/usr/bin`](#using-a-binary-outside-usrbin).
|
||||
|
||||
## Build from source
|
||||
|
||||
### Build dependencies
|
||||
|
||||
**Debian / Ubuntu 24.04+**
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y pkg-config gettext libfuse3-dev libgtk-4-dev libadwaita-1-dev \
|
||||
libwebkitgtk-6.0-dev libsecret-1-dev libdbus-1-dev
|
||||
```
|
||||
|
||||
**Fedora 44+**
|
||||
|
||||
```bash
|
||||
sudo dnf install -y cargo rust gettext pkgconf-pkg-config fuse3-devel gtk4-devel \
|
||||
libadwaita-devel webkitgtk6.0-devel libsecret-devel dbus-devel glib2-devel
|
||||
```
|
||||
|
||||
**Arch Linux**
|
||||
|
||||
```bash
|
||||
sudo pacman -S --needed rust gettext pkgconf fuse3 gtk4 libadwaita webkitgtk-6.0 libsecret dbus
|
||||
```
|
||||
|
||||
Install Rust 1.96 or newer with [rustup](https://rustup.rs) if your distribution ships an older
|
||||
toolchain.
|
||||
|
||||
### Compile
|
||||
|
||||
```bash
|
||||
git clone https://github.com/narrrl/proton-drive-linux.git
|
||||
cd proton-drive-linux
|
||||
cargo build --release --locked
|
||||
po/build.sh target/locale # optional: compile the translations
|
||||
```
|
||||
|
||||
The four binaries are in `target/release/`: `pdfs`, `pdfs-app`, `pdfs-tray` and `pdfs-prompt`.
|
||||
|
||||
### Build a package from the working tree
|
||||
|
||||
**Arch Linux:**
|
||||
|
||||
```bash
|
||||
cd packaging && makepkg -fi
|
||||
```
|
||||
|
||||
<a id="build-an-rpm-locally"></a>**Fedora:**
|
||||
|
||||
```bash
|
||||
sudo dnf install -y rpm-build
|
||||
rpmbuild -bb packaging/proton-drive-linux.spec \
|
||||
--define "git_dir $PWD" \
|
||||
--define "_rpmdir $PWD/packaging/out" \
|
||||
--define "_builddir $PWD/packaging/build" \
|
||||
--define "_sourcedir $PWD" \
|
||||
--define "_specdir $PWD/packaging" \
|
||||
--define "_srcrpmdir $PWD/packaging/out"
|
||||
sudo dnf install packaging/out/x86_64/proton-drive-linux-*.rpm
|
||||
```
|
||||
|
||||
### Using a binary outside `/usr/bin`
|
||||
|
||||
The service unit starts `/usr/bin/pdfs daemon`. If you installed the binaries somewhere else, for
|
||||
example `~/.local/bin`, install the unit for your user and point it at your binary:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cp packaging/proton-drive.service ~/.config/systemd/user/
|
||||
sed -i "s|^ExecStart=.*|ExecStart=$HOME/.local/bin/pdfs daemon|" \
|
||||
~/.config/systemd/user/proton-drive.service
|
||||
systemctl --user daemon-reload
|
||||
```
|
||||
|
||||
To start the tray at login, copy `packaging/io.narl.proton-drive-linux-tray.desktop` to
|
||||
`~/.config/autostart/`. For the app launcher entry, copy `packaging/io.narl.proton-drive-linux.desktop`
|
||||
to `~/.local/share/applications/` and the icon `packaging/io.narl.proton-drive-linux.svg` to
|
||||
`~/.local/share/icons/hicolor/scalable/apps/`.
|
||||
|
||||
## First run
|
||||
|
||||
### With the desktop app
|
||||
|
||||
1. Open **Proton Drive** from your application menu, or run `pdfs-app`.
|
||||
2. Sign in with your Proton email and password, and your two-factor code if the account has one.
|
||||
If Proton asks for a CAPTCHA (common on a VPN or a new network), the app shows the challenge in
|
||||
a window and continues after you solve it.
|
||||
3. The app enables and starts `proton-drive.service`. Your Drive appears at `~/ProtonDrive`.
|
||||
|
||||
The service is tied to your graphical session. It starts after you log in and stops when you log
|
||||
out. Change the folder location under **Preferences → General → Location**.
|
||||
|
||||
### With the command line
|
||||
|
||||
`pdfs login` stores the session but does not enable the service, so a setup that runs the daemon
|
||||
another way stays untouched. Enable the service yourself:
|
||||
|
||||
```bash
|
||||
pdfs login # email, password, 2FA code
|
||||
systemctl --user enable --now proton-drive.service
|
||||
pdfs status # account, mount and queue state
|
||||
```
|
||||
|
||||
The CLI cannot show a CAPTCHA. If sign-in reports one, sign in once with `pdfs-app` instead; the
|
||||
session it stores is used by the CLI and the service as well.
|
||||
|
||||
## Headless and server use
|
||||
|
||||
The service unit is bound to `graphical-session.target`. On a machine without a desktop session,
|
||||
run the daemon directly or from your own unit:
|
||||
|
||||
```bash
|
||||
pdfs daemon # waits for a stored session, mounts, stays mounted
|
||||
pdfs mount /srv/proton # mounts once at a given path, blocks until unmounted
|
||||
```
|
||||
|
||||
Both still need a Secret Service provider. On a server, run
|
||||
`gnome-keyring-daemon --unlock` or KeePassXC's Secret Service integration inside the user's D-Bus
|
||||
session.
|
||||
|
||||
## Updating
|
||||
|
||||
Install the new package over the old one. The service picks up the new binary the next time it
|
||||
starts; to switch at once:
|
||||
|
||||
```bash
|
||||
systemctl --user restart proton-drive.service
|
||||
```
|
||||
|
||||
Coming from 2.8.0 or older, re-enable the service once. Those releases hooked it into
|
||||
`default.target`, which starts it before the desktop session is ready; on KDE that crashes
|
||||
`ksecretd` at every login. Signing in again or toggling "Start on login" does this as well:
|
||||
|
||||
```bash
|
||||
systemctl --user reenable proton-drive.service
|
||||
```
|
||||
|
||||
Database migrations run automatically and only move forward. A database written by a newer
|
||||
release refuses to open in an older one, so downgrading means clearing the local database (see
|
||||
[Troubleshooting](TROUBLESHOOTING.md#start-over-with-a-clean-local-state)).
|
||||
|
||||
## Uninstalling
|
||||
|
||||
> [!WARNING]
|
||||
> Check that nothing is waiting to upload before you remove local state. `pdfs sync queue` must be
|
||||
> empty. Queued writes exist only on this computer until they reach Proton Drive.
|
||||
|
||||
```bash
|
||||
pdfs sync queue # must list nothing
|
||||
systemctl --user disable --now proton-drive.service
|
||||
pdfs logout # removes the session from the keyring
|
||||
```
|
||||
|
||||
Then remove the package (`sudo apt remove proton-drive-linux`, `sudo dnf remove
|
||||
proton-drive-linux`, or `sudo pacman -R proton-drive-for-linux`), and, if you want no trace left,
|
||||
the local data:
|
||||
|
||||
```bash
|
||||
rm -rf ~/.config/proton-drive-linux ~/.local/state/proton-drive-linux ~/.cache/proton-drive-linux
|
||||
```
|
||||
|
||||
Sign out of the device in your Proton account settings as well, and remove the computer from
|
||||
**Computers** in the Proton Drive web app if you no longer want its backups.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Documentation
|
||||
|
||||
## For users
|
||||
|
||||
| Page | What it covers |
|
||||
|---|---|
|
||||
| [Installation](INSTALL.md) | Packages, building from source, first sign-in, updating and uninstalling |
|
||||
| [User guide](USER_GUIDE.md) | My files, available offline, synced folders, conflicts, Photos, sharing, Trash and versions, Computers, the search launcher, the tray |
|
||||
| [Command-line reference](CLI.md) | Every `pdfs` command, grouped by task, and scripting with `--json` |
|
||||
| [Configuration](CONFIGURATION.md) | `config.json`, environment variables, file locations, the systemd service |
|
||||
| [Troubleshooting](TROUBLESHOOTING.md) | Diagnosing problems, common symptoms, starting over, reporting a bug |
|
||||
| [Recovering on a new computer](RECOVERY.md) | What survives a lost machine and how to restore your synced folders |
|
||||
| [Changelog](CHANGELOG.md) | What changed in each release |
|
||||
|
||||
## For contributors
|
||||
|
||||
| Page | What it covers |
|
||||
|---|---|
|
||||
| [Development guide](DEVELOPMENT.md) | Building, running and debugging from source; releasing |
|
||||
| [Architecture](ARCHITECTURE.md) | Crates, read and write paths, sync engine, control protocol, threads, threat model |
|
||||
| [Testing](TESTING.md) | Unit tests and the FUSE acceptance suite |
|
||||
| [Translating](TRANSLATING.md) | Adding and updating translations |
|
||||
| [Roadmap](ROADMAP.md) | Planned work and verification still owed |
|
||||
| [Bug ledger](BUGS.md) | Every tracked defect with its analysis and status |
|
||||
| [UI/UX plan](UI_UX_PLAN.md) | Design audit and notes behind the 2.x desktop app |
|
||||
|
||||
Also see [CONTRIBUTING.md](../CONTRIBUTING.md) and [SECURITY.md](../SECURITY.md) in the repository
|
||||
root.
|
||||