mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8295373f7 | ||
|
|
5bc0b5c58a | ||
|
|
73af39ee65 | ||
|
|
48aaad23ce | ||
|
|
b40ca797d9 | ||
|
|
c64bb23e3c | ||
|
|
8d4a005aa2 | ||
|
|
9460c93b50 | ||
|
|
bd50ba5959 | ||
|
|
27201914ee | ||
|
|
2775f0681d | ||
|
|
97277d41dd | ||
|
|
d49738bf0b | ||
|
|
48945b9c97 | ||
|
|
b860ac3b49 | ||
|
|
42f3a18177 | ||
|
|
6f504d2e83 | ||
|
|
8c1c37a928 | ||
|
|
e4d2340bbf | ||
|
|
c1b72e455e | ||
|
|
2e3b7748e6 | ||
|
|
7f4f11ef18 | ||
|
|
cc9f7c6d8a | ||
|
|
9add185c1f | ||
|
|
0e7ffe1261 | ||
|
|
a5acae41b3 | ||
|
|
ea10a43216 | ||
|
|
917e7268f9 | ||
|
|
9089c8d13c | ||
|
|
071a11ef65 | ||
|
|
4edcff3f87 | ||
|
|
ee59e338b6 | ||
|
|
c1d9538669 | ||
|
|
a73ed34421 | ||
|
|
9c30ff8276 | ||
|
|
d1d4d23dd5 | ||
|
|
f717a692ff | ||
|
|
e065586d7e | ||
|
|
fd80c34152 | ||
|
|
e66308498b | ||
|
|
29b87ece72 | ||
|
|
fac8a144e5 | ||
|
|
eb25c16fe6 |
No files matched your search
@@ -3,6 +3,9 @@ contact_links:
|
||||
- name: Security vulnerability
|
||||
url: https://github.com/narrrl/proton-drive-linux/security/advisories/new
|
||||
about: Report privately — never in a public issue.
|
||||
- name: Troubleshooting and FAQ
|
||||
url: https://github.com/narrrl/proton-drive-linux/wiki/Troubleshooting
|
||||
about: Fixes for common problems, and what to include in a bug report.
|
||||
- name: Known issues
|
||||
url: https://github.com/narrrl/proton-drive-linux/blob/main/docs/BUGS.md
|
||||
about: The authoritative ledger of tracked bugs and their status.
|
||||
|
||||
@@ -21,6 +21,7 @@ cargo test --workspace --locked
|
||||
- [ ] Touches recovery, staging, or anything that can be the only copy of user data
|
||||
- [ ] Changes packaging, the systemd unit, or desktop entries
|
||||
- [ ] Changes the control protocol (daemon and front ends updated together)
|
||||
- [ ] Adds or renames a page in `docs/` (listed in `SECTIONS` in `scripts/publish-wiki.sh`, which publishes it to the wiki)
|
||||
- [ ] None of the above
|
||||
|
||||
<!-- Screenshots for GTK changes. -->
|
||||
@@ -0,0 +1,66 @@
|
||||
name: COPR
|
||||
|
||||
on:
|
||||
# Called by release.yml once the GitHub release and its assets exist: COPR
|
||||
# downloads the tag archive and the vendored crates from the release.
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
COPR_CONFIG:
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Release to build, such as 3.5.0. It must already have its vendor tarball.'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: copr
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build in COPR
|
||||
runs-on: ubuntu-24.04
|
||||
container: fedora:latest
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
COPR_PROJECT: narl-rs/proton-drive-linux
|
||||
|
||||
steps:
|
||||
- name: Install packaging tools
|
||||
run: dnf -y install copr-cli rpm-build rpmdevtools
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Configure COPR access
|
||||
env:
|
||||
COPR_CONFIG: ${{ secrets.COPR_CONFIG }}
|
||||
run: |
|
||||
install -d -m700 ~/.config
|
||||
printf '%s\n' "$COPR_CONFIG" > ~/.config/copr
|
||||
chmod 600 ~/.config/copr
|
||||
|
||||
# COPR rebuilds the source package without our --define, so the version
|
||||
# has to be written into the spec itself.
|
||||
- name: Build the source package
|
||||
run: |
|
||||
version="${VERSION#v}"
|
||||
spec=packaging/proton-drive-linux.spec
|
||||
sed -i -E "s/^(%\{!\?version: %global version )[^}]*\}/\1${version}}/" "$spec"
|
||||
grep -qF "%global version ${version}}" "$spec"
|
||||
mkdir -p rpmbuild/SOURCES
|
||||
spectool -g -C rpmbuild/SOURCES "$spec"
|
||||
rpmbuild -bs --define "_topdir $PWD/rpmbuild" "$spec"
|
||||
|
||||
# Waits for the build, so a failed COPR build fails this job.
|
||||
- name: Submit to COPR
|
||||
run: copr-cli build "$COPR_PROJECT" rpmbuild/SRPMS/*.src.rpm
|
||||
@@ -52,8 +52,10 @@ jobs:
|
||||
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)"
|
||||
spec_version="$(sed -n 's/^%{!?version: %global version \([^}]*\)}/\1/p' packaging/proton-drive-linux.spec)"
|
||||
test "$tag_version" = "$manifest_version"
|
||||
test "$tag_version" = "$pkgbuild_version"
|
||||
test "$tag_version" = "$spec_version"
|
||||
|
||||
- name: Release quality gates
|
||||
run: |
|
||||
@@ -129,6 +131,12 @@ jobs:
|
||||
# Create tarball
|
||||
tar -czf proton-drive-linux-${{ env.VERSION }}-x86_64.tar.gz -C dist/binaries .
|
||||
|
||||
# Every crate the build needs, so COPR can build the spec without network.
|
||||
- name: Package vendored crates (.tar.xz)
|
||||
run: |
|
||||
cargo vendor --locked dist/vendor > /dev/null
|
||||
tar -cJf proton-drive-linux-vendor-${{ env.VERSION }}.tar.xz -C dist vendor
|
||||
|
||||
- name: Package Debian package (.deb)
|
||||
run: |
|
||||
mkdir -p deb/DEBIAN
|
||||
@@ -230,6 +238,7 @@ jobs:
|
||||
path: |
|
||||
*.deb
|
||||
*.tar.gz
|
||||
*.tar.xz
|
||||
*.rpm
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -297,6 +306,7 @@ jobs:
|
||||
files: |
|
||||
*.deb
|
||||
*.tar.gz
|
||||
*.tar.xz
|
||||
*.rpm
|
||||
draft: false
|
||||
prerelease: false
|
||||
@@ -312,3 +322,21 @@ jobs:
|
||||
version: ${{ github.ref_name }}
|
||||
secrets:
|
||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
|
||||
copr:
|
||||
name: Build COPR packages
|
||||
needs: publish
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: ./.github/workflows/copr.yml
|
||||
with:
|
||||
version: ${{ github.ref_name }}
|
||||
secrets:
|
||||
COPR_CONFIG: ${{ secrets.COPR_CONFIG }}
|
||||
|
||||
wiki:
|
||||
name: Publish the wiki
|
||||
needs: publish
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: ./.github/workflows/wiki.yml
|
||||
with:
|
||||
ref: ${{ github.ref_name }}
|
||||
@@ -0,0 +1,77 @@
|
||||
name: Wiki
|
||||
|
||||
# docs/ is the source of the GitHub wiki (docs/DEVELOPMENT.md#common-changes).
|
||||
# Every change to the documentation checks its links; release.yml calls this
|
||||
# once the release is published, so the wiki documents the latest release. Run
|
||||
# it from the Actions tab to publish main in between.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'images/**'
|
||||
- 'README.md'
|
||||
- 'CONTRIBUTING.md'
|
||||
- 'SECURITY.md'
|
||||
- 'scripts/publish-wiki.sh'
|
||||
- '.github/workflows/wiki.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'images/**'
|
||||
- 'README.md'
|
||||
- 'CONTRIBUTING.md'
|
||||
- 'SECURITY.md'
|
||||
- 'scripts/publish-wiki.sh'
|
||||
- '.github/workflows/wiki.yml'
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Tag or branch to publish'
|
||||
required: true
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Tag or branch to publish'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check documentation links
|
||||
if: inputs.ref == ''
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Check links
|
||||
run: scripts/publish-wiki.sh --check "$RUNNER_TEMP"
|
||||
|
||||
publish:
|
||||
name: Publish to the wiki
|
||||
# Set only by release.yml and the Actions tab, so pushes and pull requests
|
||||
# never publish.
|
||||
if: inputs.ref != ''
|
||||
runs-on: ubuntu-24.04
|
||||
concurrency:
|
||||
group: wiki-publish
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Publish
|
||||
env:
|
||||
WIKI_REMOTE: https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.wiki.git
|
||||
run: |
|
||||
git config --global user.name 'github-actions[bot]'
|
||||
git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
scripts/publish-wiki.sh --push "$RUNNER_TEMP"
|
||||
+8
-6
@@ -6,10 +6,10 @@ deliberately high — everything else is ordinary Rust review.
|
||||
|
||||
## Before you start
|
||||
|
||||
- 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.
|
||||
- Read [`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. The same pages are on
|
||||
the [wiki](https://github.com/narrrl/proton-drive-linux/wiki).
|
||||
- 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
|
||||
@@ -58,8 +58,10 @@ runs are destructive: use a dedicated test account, never one holding data you c
|
||||
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).
|
||||
under `docs/` (a user guide topic, the CLI reference or configuration) and in the `[Unreleased]`
|
||||
section of [`docs/CHANGELOG.md`](docs/CHANGELOG.md). Each release publishes `docs/` to the
|
||||
wiki. A new page goes into `SECTIONS` in `scripts/publish-wiki.sh`, and
|
||||
`scripts/publish-wiki.sh --check` must pass. CI runs it on every change to `docs/`.
|
||||
|
||||
## Commits and pull requests
|
||||
|
||||
|
||||
Generated
+8
-8
@@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-cli"
|
||||
version = "3.5.0"
|
||||
version = "3.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3490,7 +3490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-core"
|
||||
version = "3.5.0"
|
||||
version = "3.6.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -3517,7 +3517,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-fuse"
|
||||
version = "3.5.0"
|
||||
version = "3.6.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -3539,7 +3539,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-gui"
|
||||
version = "3.5.0"
|
||||
version = "3.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3779,9 +3779,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-drive-rs"
|
||||
version = "0.9.1"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e6b87f78711e8fc72737c8eb24ebda156079308d79ab37ca2b1b32de6812880"
|
||||
checksum = "8f70318394b2815b009c52f804d75747086db93a338ceb6eae62541f2b25d5bf"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -3806,9 +3806,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-sdk"
|
||||
version = "0.9.1"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19f1936755c603b98de1c82a5b343c6232e9fea8adaa77139f7cb8fcddcda697"
|
||||
checksum = "4196392032f56b3bbaac0bb1f24eea62fa7e2a0af72004de8d08e518d14720fb"
|
||||
dependencies = [
|
||||
"aes-gcm 0.11.1",
|
||||
"async-trait",
|
||||
|
||||
+3
-3
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "3.5.0"
|
||||
version = "3.6.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
||||
categories = ["filesystem", "command-line-utilities"]
|
||||
|
||||
[workspace.dependencies]
|
||||
proton-sdk = { version = "0.9.1" }
|
||||
proton-drive-rs = { version = "0.9.1" }
|
||||
proton-sdk = { version = "0.10.0" }
|
||||
proton-drive-rs = { version = "0.10.0" }
|
||||
|
||||
# Internal crates.
|
||||
pdfs-core = { path = "crates/pdfs-core" }
|
||||
|
||||
@@ -73,16 +73,30 @@ scriptable CLI.
|
||||
|
||||
| Distribution | Package |
|
||||
|---|---|
|
||||
| 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) |
|
||||
| Fedora 44 or newer | [`narl-rs/proton-drive-linux`](https://copr.fedorainfracloud.org/coprs/narl-rs/proton-drive-linux/) in COPR, updated with `dnf upgrade` |
|
||||
| Arch Linux | [`proton-drive-for-linux`](https://aur.archlinux.org/packages/proton-drive-for-linux) (also `-bin` and `-git`) in the AUR |
|
||||
| NixOS | The flake in this repository: see [Install → NixOS](docs/INSTALL.md#nixos-flake) |
|
||||
| Debian, Ubuntu 24.04 or newer | `.deb` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
|
||||
| NixOS | The flake in this repository: see [Installation → NixOS](docs/INSTALL.md#nixos-flake) |
|
||||
| Anything else | [Build from source](docs/INSTALL.md#build-from-source) |
|
||||
|
||||
**Fedora**
|
||||
|
||||
```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
|
||||
sudo dnf copr enable narl-rs/proton-drive-linux
|
||||
sudo dnf install proton-drive-linux
|
||||
```
|
||||
|
||||
**Arch Linux**
|
||||
|
||||
```bash
|
||||
paru -S proton-drive-for-linux
|
||||
```
|
||||
|
||||
**Debian and Ubuntu:** download the `.deb` from the
|
||||
[latest release](https://github.com/narrrl/proton-drive-linux/releases/latest), then:
|
||||
|
||||
```bash
|
||||
sudo apt install ./proton-drive-linux_*_amd64.deb
|
||||
```
|
||||
|
||||
Then open **Proton Drive** from your applications menu and sign in. The app starts the background
|
||||
@@ -97,14 +111,20 @@ systemctl --user enable --now proton-drive.service
|
||||
pdfs status
|
||||
```
|
||||
|
||||
[docs/INSTALL.md](docs/INSTALL.md) has the requirements, optional extras (tray on GNOME, RAW and
|
||||
video thumbnails), headless use and uninstalling.
|
||||
New to the client? [Getting started](docs/GETTING_STARTED.md) walks through the first steps.
|
||||
[Installation](docs/INSTALL.md) has the optional extras (tray on GNOME, RAW and video
|
||||
thumbnails), updating, headless use and uninstalling.
|
||||
|
||||
## Documentation
|
||||
|
||||
The documentation is in [`docs/`](docs/README.md) and on the
|
||||
[wiki](https://github.com/narrrl/proton-drive-linux/wiki).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [Getting started](docs/GETTING_STARTED.md) | From installing to your first synced folder |
|
||||
| [User guide](docs/USER_GUIDE.md) | Everything the app, tray and launcher do |
|
||||
| [FAQ](docs/FAQ.md) | Short answers to common questions |
|
||||
| [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 |
|
||||
@@ -151,7 +171,8 @@ keys before it leaves the machine, through a Rust implementation of Proton's Dri
|
||||
|
||||
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#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||
[security model](docs/SECURITY_MODEL.md#what-is-kept-on-this-computer), and in full detail in
|
||||
the [threat model](docs/ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Log priorities for journald.
|
||||
//!
|
||||
//! journald files each line a service writes at the unit's default priority,
|
||||
//! info, unless the line opens with a syslog `<N>` prefix. Without one every
|
||||
//! WARN and ERROR the daemon logs is stored as info, and the
|
||||
//! `journalctl -p warning` that the troubleshooting guide asks for shows
|
||||
//! nothing (`docs/BUGS.md` B258).
|
||||
|
||||
use std::fmt;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
use tracing::{Event, Level, Subscriber};
|
||||
use tracing_subscriber::fmt::format::Writer;
|
||||
use tracing_subscriber::fmt::{FmtContext, FormatEvent, FormatFields};
|
||||
use tracing_subscriber::registry::LookupSpan;
|
||||
|
||||
/// Whether standard output is the journal stream systemd connected this
|
||||
/// process to.
|
||||
///
|
||||
/// `JOURNAL_STREAM` alone does not say so: a terminal started from a user unit
|
||||
/// inherits it, and every `pdfs` run in that terminal would print the
|
||||
/// prefixes. systemd sets it to the stream's `device:inode`, so it is compared
|
||||
/// against what fd 1 actually is.
|
||||
pub(crate) fn stdout_is_journal() -> bool {
|
||||
let Some(stream) = std::env::var_os("JOURNAL_STREAM") else {
|
||||
return false;
|
||||
};
|
||||
let Ok(stdout) = std::fs::metadata("/proc/self/fd/1") else {
|
||||
return false;
|
||||
};
|
||||
stream
|
||||
.to_str()
|
||||
.is_some_and(|value| names_stream(value, stdout.dev(), stdout.ino()))
|
||||
}
|
||||
|
||||
/// Whether a `JOURNAL_STREAM` value names the file `dev`:`ino`.
|
||||
fn names_stream(value: &str, dev: u64, ino: u64) -> bool {
|
||||
value
|
||||
.split_once(':')
|
||||
.is_some_and(|(d, i)| d.parse() == Ok(dev) && i.parse() == Ok(ino))
|
||||
}
|
||||
|
||||
/// The syslog priority journald should file a `level` line at.
|
||||
fn priority(level: &Level) -> u8 {
|
||||
match *level {
|
||||
Level::ERROR => 3,
|
||||
Level::WARN => 4,
|
||||
Level::INFO => 6,
|
||||
Level::DEBUG | Level::TRACE => 7,
|
||||
}
|
||||
}
|
||||
|
||||
/// An event format that opens each line with its `<N>` priority, then writes
|
||||
/// the line as `F` would.
|
||||
pub(crate) struct Prioritized<F>(pub(crate) F);
|
||||
|
||||
impl<S, N, F> FormatEvent<S, N> for Prioritized<F>
|
||||
where
|
||||
S: Subscriber + for<'a> LookupSpan<'a>,
|
||||
N: for<'a> FormatFields<'a> + 'static,
|
||||
F: FormatEvent<S, N>,
|
||||
{
|
||||
fn format_event(
|
||||
&self,
|
||||
ctx: &FmtContext<'_, S, N>,
|
||||
mut writer: Writer<'_>,
|
||||
event: &Event<'_>,
|
||||
) -> fmt::Result {
|
||||
write!(writer, "<{}>", priority(event.metadata().level()))?;
|
||||
self.0.format_event(ctx, writer, event)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Buf(Arc<Mutex<Vec<u8>>>);
|
||||
|
||||
impl io::Write for Buf {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.0.lock().unwrap().extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_line_opens_with_the_priority_of_its_level() {
|
||||
let buf = Buf(Arc::new(Mutex::new(Vec::new())));
|
||||
let writer = buf.clone();
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_max_level(Level::DEBUG)
|
||||
.with_ansi(false)
|
||||
.with_writer(move || writer.clone())
|
||||
.event_format(Prioritized(tracing_subscriber::fmt::format()))
|
||||
.finish();
|
||||
tracing::subscriber::with_default(subscriber, || {
|
||||
tracing::error!("e");
|
||||
tracing::warn!("w");
|
||||
tracing::info!("i");
|
||||
tracing::debug!("d");
|
||||
});
|
||||
let out = String::from_utf8(buf.0.lock().unwrap().clone()).unwrap();
|
||||
let prefixes: Vec<&str> = out.lines().map(|line| &line[..3]).collect();
|
||||
assert_eq!(prefixes, ["<3>", "<4>", "<6>", "<7>"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_stream_must_name_the_same_file() {
|
||||
assert!(names_stream("8:12345", 8, 12345));
|
||||
assert!(!names_stream("8:12345", 8, 54321));
|
||||
assert!(!names_stream("9:12345", 8, 12345));
|
||||
assert!(!names_stream("8", 8, 12345));
|
||||
assert!(!names_stream("", 8, 12345));
|
||||
}
|
||||
}
|
||||
+256
-15
@@ -13,13 +13,15 @@ use pdfs_core::auth;
|
||||
use pdfs_core::cache::ContentCache;
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
ConflictKeep, ErrorKind, MountKind, RefreshScope, Request as CtlRequest,
|
||||
ConflictKeep, ErrorKind, LinkChange, MountKind, RefreshScope, Request as CtlRequest,
|
||||
Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncIssue, SyncPhase,
|
||||
daemon_outdated, pending_summary,
|
||||
};
|
||||
use pdfs_core::db::Db;
|
||||
use pdfs_core::service;
|
||||
|
||||
mod journal;
|
||||
|
||||
/// `println!` for this binary, shadowing std's, which panics when stdout is
|
||||
/// closed: `pdfs ls | head` ended in a "Broken pipe" panic once `head` had its
|
||||
/// lines. This one hands the failure to [`stdout_written`] instead.
|
||||
@@ -356,6 +358,26 @@ enum Command {
|
||||
#[arg(required = true, num_args = 2.., value_name = "PATH")]
|
||||
paths: Vec<PathBuf>,
|
||||
},
|
||||
/// Copy files or folders into another folder in My files via the running
|
||||
/// daemon.
|
||||
///
|
||||
/// The copy happens on Proton Drive, so nothing is downloaded or uploaded,
|
||||
/// unlike `cp` inside the mount. A name already taken in the destination
|
||||
/// gets a " (1)" suffix. The last path is the destination folder.
|
||||
Copy {
|
||||
/// Files/folders to copy, then the destination folder: inside My files,
|
||||
/// or relative to the mountpoint.
|
||||
#[arg(required = true, num_args = 2.., value_name = "PATH")]
|
||||
paths: Vec<PathBuf>,
|
||||
},
|
||||
/// Show how much a folder holds on Proton Drive.
|
||||
///
|
||||
/// Proton Drive adds it up, so nothing is downloaded. The size is the
|
||||
/// encrypted bytes on storage and includes items in the trash.
|
||||
Du {
|
||||
/// Folder path, inside My files or relative to the mountpoint.
|
||||
path: PathBuf,
|
||||
},
|
||||
/// Trash a file or folder via the running daemon.
|
||||
Rm {
|
||||
/// File/folder path, inside My files or an on-demand synced folder, or
|
||||
@@ -689,6 +711,13 @@ enum SyncCmd {
|
||||
/// Optional synced-folder id; omit to reconcile every folder.
|
||||
id: Option<i64>,
|
||||
},
|
||||
/// Forget what a synced folder synced before and compare it with Proton
|
||||
/// Drive afresh: what one side has alone is copied to the other, and a file
|
||||
/// that differs keeps both versions. Nothing is deleted.
|
||||
Reset {
|
||||
/// Synced-folder id (from `sync list`).
|
||||
id: i64,
|
||||
},
|
||||
/// Pause syncing: queued uploads and mirror folders wait, reads keep working.
|
||||
Pause {
|
||||
/// End the pause by itself after this long, e.g. `30m`, `1h`, `2d`.
|
||||
@@ -777,6 +806,29 @@ enum PublicLinkCmd {
|
||||
#[arg(long)]
|
||||
expires: Option<i64>,
|
||||
},
|
||||
/// Change a node's public link. The URL stays the same; an option not
|
||||
/// given is kept.
|
||||
Update {
|
||||
/// File/folder path, inside the mountpoint or relative to it.
|
||||
path: PathBuf,
|
||||
/// Public-link id, from `pdfs members`.
|
||||
id: String,
|
||||
/// New role: viewer or editor.
|
||||
#[arg(long)]
|
||||
role: Option<String>,
|
||||
/// New custom password protecting the link.
|
||||
#[arg(long, conflicts_with = "no_password")]
|
||||
password: Option<String>,
|
||||
/// Remove the custom password.
|
||||
#[arg(long)]
|
||||
no_password: bool,
|
||||
/// New expiry, Unix epoch seconds.
|
||||
#[arg(long, conflicts_with = "no_expiry")]
|
||||
expires: Option<i64>,
|
||||
/// Remove the expiry.
|
||||
#[arg(long)]
|
||||
no_expiry: bool,
|
||||
},
|
||||
/// Remove a node's public link.
|
||||
Remove {
|
||||
/// File/folder path, inside the mountpoint or relative to it.
|
||||
@@ -829,15 +881,22 @@ fn cli_error(kind: ErrorKind, message: &str) -> String {
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
// `pgp` logs a benign WARN when it re-serializes a Proton secret-key
|
||||
// packet with a longer (but equivalent) length encoding than the
|
||||
// original; drop it to error so normal runs stay quiet.
|
||||
.unwrap_or_else(|_| "info,pgp=error".into()),
|
||||
)
|
||||
.init();
|
||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
// `pgp` logs a benign WARN when it re-serializes a Proton secret-key
|
||||
// packet with a longer (but equivalent) length encoding than the
|
||||
// original; drop it to error so normal runs stay quiet.
|
||||
.unwrap_or_else(|_| "info,pgp=error".into());
|
||||
if journal::stdout_is_journal() {
|
||||
// journalctl colours lines by the priority the prefix gives them, so
|
||||
// the escape codes would only be noise in the stored text.
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_ansi(false)
|
||||
.event_format(journal::Prioritized(tracing_subscriber::fmt::format()))
|
||||
.init();
|
||||
} else {
|
||||
tracing_subscriber::fmt().with_env_filter(filter).init();
|
||||
}
|
||||
|
||||
let cli = Cli::parse();
|
||||
JSON_OUTPUT.store(cli.json, std::sync::atomic::Ordering::Relaxed);
|
||||
@@ -919,6 +978,11 @@ fn main() -> Result<()> {
|
||||
let new_parent = paths.pop().expect("clap requires at least two paths");
|
||||
cmd_move(paths, new_parent)
|
||||
}
|
||||
Command::Copy { mut paths } => {
|
||||
let new_parent = paths.pop().expect("clap requires at least two paths");
|
||||
cmd_copy(paths, new_parent)
|
||||
}
|
||||
Command::Du { path } => cmd_du(path),
|
||||
Command::Rm { path } => cmd_rm(path),
|
||||
Command::Mkdir { parent, name } => cmd_mkdir(parent, name),
|
||||
Command::Upload { sources, parent } => cmd_upload(sources, parent),
|
||||
@@ -1088,7 +1152,14 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
|
||||
// A pass in flight says what it is doing, indented under it.
|
||||
if let Some(p) = &f.progress {
|
||||
match p.phase {
|
||||
SyncPhase::Scanning if p.total == 0 => println!(" scanning…"),
|
||||
SyncPhase::Scanning if p.total == 0 && p.done == 0 => {
|
||||
println!(" scanning…")
|
||||
}
|
||||
// A first pass has no estimate yet; the count alone
|
||||
// shows a long first scan is moving.
|
||||
SyncPhase::Scanning if p.total == 0 => {
|
||||
println!(" scanning: {} checked", p.done)
|
||||
}
|
||||
SyncPhase::Scanning => {
|
||||
println!(
|
||||
" scanning: {} of {}",
|
||||
@@ -1117,6 +1188,7 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
|
||||
})?)?
|
||||
}
|
||||
SyncCmd::Now { id } => ok_or_bail(control_request(CtlRequest::SyncNow { id })?)?,
|
||||
SyncCmd::Reset { id } => ok_or_bail(control_request(CtlRequest::ResetSyncFolder { id })?)?,
|
||||
SyncCmd::Pause {
|
||||
folder: Some(id), ..
|
||||
} => ok_or_bail(control_request(CtlRequest::SetSyncFolderPaused {
|
||||
@@ -1493,9 +1565,13 @@ fn cmd_versions(action: VersionCmd) -> Result<()> {
|
||||
}
|
||||
|
||||
fn cmd_members(path: PathBuf) -> Result<()> {
|
||||
match control_request(CtlRequest::ListShare {
|
||||
let response = control_request(CtlRequest::ListShare {
|
||||
path: path_arg(&path)?,
|
||||
})? {
|
||||
})?;
|
||||
if emit_json(&response)? {
|
||||
return Ok(());
|
||||
}
|
||||
match response {
|
||||
CtlResponse::Share { entries, link } => {
|
||||
if entries.is_empty() {
|
||||
println!("No members or pending invitations.");
|
||||
@@ -1512,7 +1588,11 @@ fn cmd_members(path: PathBuf) -> Result<()> {
|
||||
Some(l) => {
|
||||
let url = l.url.as_deref().unwrap_or("(url hidden)");
|
||||
let pw = if l.has_password { " +password" } else { "" };
|
||||
println!("public link ({}{pw}): {url} [{}]", l.role, l.id);
|
||||
let expiry = l
|
||||
.expires
|
||||
.map(|at| format!(", expires {}", format_epoch_utc(at)))
|
||||
.unwrap_or_default();
|
||||
println!("public link ({}{pw}{expiry}): {url} [{}]", l.role, l.id);
|
||||
}
|
||||
None => println!("public link: none"),
|
||||
}
|
||||
@@ -1558,6 +1638,29 @@ fn cmd_public_link(action: PublicLinkCmd) -> Result<()> {
|
||||
password,
|
||||
expires,
|
||||
})? {
|
||||
response if emit_json(&response)? => {}
|
||||
CtlResponse::PublicLink { link } => {
|
||||
println!("{}", link.url.as_deref().unwrap_or("(no url returned)"));
|
||||
}
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
},
|
||||
PublicLinkCmd::Update {
|
||||
path,
|
||||
id,
|
||||
role,
|
||||
password,
|
||||
no_password,
|
||||
expires,
|
||||
no_expiry,
|
||||
} => match control_request(CtlRequest::UpdatePublicLink {
|
||||
path: path_arg(&path)?,
|
||||
id,
|
||||
role,
|
||||
password: link_change(password, no_password),
|
||||
expires: link_change(expires, no_expiry),
|
||||
})? {
|
||||
response if emit_json(&response)? => {}
|
||||
CtlResponse::PublicLink { link } => {
|
||||
println!("{}", link.url.as_deref().unwrap_or("(no url returned)"));
|
||||
}
|
||||
@@ -1574,6 +1677,15 @@ fn cmd_public_link(action: PublicLinkCmd) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A `--x VALUE` / `--no-x` flag pair as one link option change.
|
||||
fn link_change<T>(value: Option<T>, remove: bool) -> LinkChange<T> {
|
||||
match (value, remove) {
|
||||
(Some(value), _) => LinkChange::Set(value),
|
||||
(None, true) => LinkChange::Remove,
|
||||
(None, false) => LinkChange::Keep,
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_shared() -> Result<()> {
|
||||
match control_request(CtlRequest::ListSharedByMe)? {
|
||||
CtlResponse::SharedByMe { items } if items.is_empty() => {
|
||||
@@ -1765,6 +1877,7 @@ fn activity_verb(kind: pdfs_core::control::ActivityKind) -> &'static str {
|
||||
Sync => "sync",
|
||||
Rename => "rename",
|
||||
Move => "move",
|
||||
Copy => "copy",
|
||||
CreateFolder => "mkdir",
|
||||
Trash => "trash",
|
||||
Restore => "restore",
|
||||
@@ -2920,6 +3033,56 @@ fn cmd_move(sources: Vec<PathBuf>, new_parent: PathBuf) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_copy(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::Copy {
|
||||
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 copied", sources.len());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_du(path: PathBuf) -> Result<()> {
|
||||
let response = control_request(CtlRequest::FolderSize {
|
||||
path: path_arg(&path)?,
|
||||
})?;
|
||||
if emit_json(&response)? {
|
||||
return Ok(());
|
||||
}
|
||||
match response {
|
||||
CtlResponse::FolderSize { size, items } => {
|
||||
let noun = if items == 1 { "item" } else { "items" };
|
||||
println!(
|
||||
"{} in {items} {noun} (on Proton Drive, trash included)",
|
||||
human_bytes(size)
|
||||
);
|
||||
}
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_rm(path: PathBuf) -> Result<()> {
|
||||
match control_request(CtlRequest::Delete {
|
||||
path: path_arg(&path)?,
|
||||
@@ -2942,6 +3105,24 @@ fn human_secs(secs: u64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The sync engine's line in `pdfs diagnostics`: whether it runs, and which
|
||||
/// folder's pass it is on.
|
||||
fn sync_engine_line(sync: &pdfs_core::control::SyncEngineState) -> String {
|
||||
let mut line = match (sync.running, sync.pass_folder) {
|
||||
(false, _) => "STOPPED; synced folders do not sync until the daemon restarts".to_string(),
|
||||
(true, None) => "idle".to_string(),
|
||||
(true, Some(folder)) => format!(
|
||||
"pass on folder {folder} for {}, last progress {} ago",
|
||||
human_secs(sync.pass_age_secs),
|
||||
human_secs(sync.pass_idle_secs),
|
||||
),
|
||||
};
|
||||
if sync.panics > 0 {
|
||||
line.push_str(&format!("; {} pass(es) panicked", sync.panics));
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
fn cmd_diagnostics() -> Result<()> {
|
||||
let response = control_request(CtlRequest::Diagnostics)?;
|
||||
if emit_json(&response)? {
|
||||
@@ -2977,6 +3158,9 @@ fn cmd_diagnostics() -> Result<()> {
|
||||
human_secs(worker.age_secs)
|
||||
);
|
||||
}
|
||||
if let Some(sync) = &d.sync {
|
||||
println!("sync engine: {}", sync_engine_line(sync));
|
||||
}
|
||||
if d.inflight.is_empty() {
|
||||
println!("in-flight requests: none");
|
||||
} else {
|
||||
@@ -3519,10 +3703,13 @@ fn cmd_diagnose() -> Result<()> {
|
||||
);
|
||||
|
||||
println!("\nAccount");
|
||||
let mut keys_unlocked = false;
|
||||
match pdfs_core::auth::load() {
|
||||
Ok(session) => {
|
||||
report.finding(DiagnoseLevel::Ok, " keyring session", session.username);
|
||||
match key_state_name() {
|
||||
let key_state = key_state_name();
|
||||
keys_unlocked = key_state != "locked";
|
||||
match key_state {
|
||||
"locked" => report.finding(
|
||||
DiagnoseLevel::Fail,
|
||||
" account keys",
|
||||
@@ -3561,6 +3748,7 @@ fn cmd_diagnose() -> Result<()> {
|
||||
);
|
||||
// A stale socket file with no listener is the common failure, so the reply
|
||||
// is what decides this rather than the file's existence.
|
||||
let mut daemon_responding = false;
|
||||
match control_request(CtlRequest::Status) {
|
||||
Ok(CtlResponse::Status {
|
||||
mountpoint,
|
||||
@@ -3572,6 +3760,7 @@ fn cmd_diagnose() -> Result<()> {
|
||||
version,
|
||||
..
|
||||
}) => {
|
||||
daemon_responding = true;
|
||||
report.finding(DiagnoseLevel::Ok, " daemon responding", "");
|
||||
if daemon_outdated(&version) {
|
||||
report.finding(
|
||||
@@ -3713,6 +3902,14 @@ fn cmd_diagnose() -> Result<()> {
|
||||
mountpoint.display().to_string(),
|
||||
);
|
||||
|
||||
// The daemon opens its control socket only once My Files is mounted, so a
|
||||
// daemon that answers has the keys it needs. One that does not may be stuck
|
||||
// on a key that does not open (#40); only then is Proton asked.
|
||||
if keys_unlocked && !daemon_responding {
|
||||
println!("\nKeys");
|
||||
diagnose_keys(&mut report);
|
||||
}
|
||||
|
||||
if report.failures == 0 {
|
||||
if report.warnings == 0 {
|
||||
println!("\nNo problems found.");
|
||||
@@ -3732,6 +3929,43 @@ fn cmd_diagnose() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask Proton which account and address keys open, and which of them the My
|
||||
/// Files share needs. The findings hold key IDs and no secrets.
|
||||
fn diagnose_keys(report: &mut DiagnoseReport) {
|
||||
use pdfs_core::keycheck::{self, Level};
|
||||
|
||||
let checked = tokio::runtime::Runtime::new()
|
||||
.map_err(anyhow::Error::from)
|
||||
.and_then(|rt| {
|
||||
rt.block_on(async {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(30), keycheck::run())
|
||||
.await
|
||||
.map_err(|_| anyhow!("Proton did not answer within 30 seconds"))?
|
||||
.map_err(anyhow::Error::from)
|
||||
})
|
||||
});
|
||||
let (keys, share) = match checked {
|
||||
Ok(checked) => checked,
|
||||
Err(e) => {
|
||||
report.finding(DiagnoseLevel::Warn, " key check", format!("{e:#}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let findings = keycheck::findings(&keys, &share);
|
||||
let any_failed = findings.iter().any(|f| f.level != Level::Ok);
|
||||
for finding in findings {
|
||||
let level = match finding.level {
|
||||
Level::Ok => DiagnoseLevel::Ok,
|
||||
Level::Warn => DiagnoseLevel::Warn,
|
||||
Level::Fail => DiagnoseLevel::Fail,
|
||||
};
|
||||
report.finding(level, &format!(" {}", finding.label), finding.detail);
|
||||
}
|
||||
if any_failed {
|
||||
println!(" (key IDs are not secret; include this section when you report it)");
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one request to the running mount daemon's control socket and read its
|
||||
/// reply. Errors if no daemon is listening.
|
||||
fn control_request(req: CtlRequest) -> Result<CtlResponse> {
|
||||
@@ -3906,6 +4140,13 @@ mod login_wait_tests {
|
||||
assert_eq!(slept, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_flag_pair_keeps_removes_or_sets() {
|
||||
assert_eq!(link_change::<i64>(None, false), LinkChange::Keep);
|
||||
assert_eq!(link_change::<i64>(None, true), LinkChange::Remove);
|
||||
assert_eq!(link_change(Some(5), false), LinkChange::Set(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epoch_seconds_format_as_utc_calendar_dates() {
|
||||
assert_eq!(format_epoch_utc(0), "1970-01-01");
|
||||
|
||||
@@ -190,6 +190,24 @@ pub struct AppConfig {
|
||||
/// the field.
|
||||
#[serde(default)]
|
||||
pub local_first: Option<bool>,
|
||||
/// Ask GitHub once a day whether a newer release is out, and say so in the
|
||||
/// desktop app. Off by default: GitHub then sees the address and version
|
||||
/// asking. Never used where a package manager installs updates (see
|
||||
/// [`crate::update::updates_managed`]). Defaulted for configs predating the
|
||||
/// field.
|
||||
#[serde(default)]
|
||||
pub update_check: bool,
|
||||
/// When the update check last asked GitHub, in Unix seconds, so a restart
|
||||
/// does not ask again within [`crate::update::CHECK_INTERVAL`]. Defaulted
|
||||
/// for configs predating the field.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub update_last_checked: Option<i64>,
|
||||
/// The latest release that check found, such as `3.6.0`, so the app can
|
||||
/// say so again after a restart without asking. Older than or equal to the
|
||||
/// running version once the update is installed. Defaulted for configs
|
||||
/// predating the field.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub update_latest: Option<String>,
|
||||
}
|
||||
|
||||
/// The My Files page's view choices, remembered between runs.
|
||||
@@ -753,6 +771,9 @@ mod tests {
|
||||
language: Some("de".to_string()),
|
||||
online_map: true,
|
||||
local_first: Some(false),
|
||||
update_check: true,
|
||||
update_last_checked: Some(1_760_000_000),
|
||||
update_latest: Some("3.6.0".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||
@@ -764,6 +785,9 @@ mod tests {
|
||||
assert_eq!(decoded.proton_theme, Some(true));
|
||||
assert!(decoded.tray_hidden);
|
||||
assert!(decoded.online_map);
|
||||
assert!(decoded.update_check);
|
||||
assert_eq!(decoded.update_last_checked, Some(1_760_000_000));
|
||||
assert_eq!(decoded.update_latest.as_deref(), Some("3.6.0"));
|
||||
assert_eq!(decoded.language.as_deref(), Some("de"));
|
||||
assert_eq!(decoded.upload_limit, Some(500_000));
|
||||
assert_eq!(decoded.download_limit, None);
|
||||
|
||||
@@ -344,6 +344,17 @@ pub enum Request {
|
||||
/// 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 },
|
||||
/// Copy a file or folder into a folder on Proton Drive, without
|
||||
/// downloading or uploading it. Both `path` and `new_parent` are
|
||||
/// mountpoint-relative, or absolute paths inside My files. A name already
|
||||
/// taken in `new_parent` gets the next free ` (n)` suffix. Replies with
|
||||
/// [`Response::Ok`].
|
||||
Copy { path: String, new_parent: String },
|
||||
/// Ask Proton Drive how much the folder at mountpoint-relative `path`
|
||||
/// holds. Replies with [`Response::FolderSize`].
|
||||
FolderSize { path: String },
|
||||
/// The by-uid twin of [`Request::FolderSize`].
|
||||
FolderSizeByUid { uid: String },
|
||||
/// Trash a file or folder. `path` is mountpoint-relative. Replies with
|
||||
/// [`Response::Ok`].
|
||||
Delete { path: String },
|
||||
@@ -469,6 +480,11 @@ pub enum Request {
|
||||
/// Pause or resume one mirror folder's reconcile, independent of the
|
||||
/// global pause. Persisted until resumed. Replies with [`Response::Ok`].
|
||||
SetSyncFolderPaused { id: i64, paused: bool },
|
||||
/// Forget what a mirror folder synced before, so its next pass compares
|
||||
/// both sides afresh: what is on one side only is copied to the other, and
|
||||
/// a file that differs keeps both versions. Nothing is deleted. Replies
|
||||
/// with [`Response::Ok`].
|
||||
ResetSyncFolder { id: i64 },
|
||||
/// Force a reconcile pass: one folder by id, or all when `id` is `None`.
|
||||
/// Replies with [`Response::Ok`].
|
||||
SyncNow { id: Option<i64> },
|
||||
@@ -596,6 +612,24 @@ pub enum Request {
|
||||
RemovePublicLink { path: String, id: String },
|
||||
/// The by-uid twin of [`Request::RemovePublicLink`].
|
||||
RemovePublicLinkByUid { uid: String, id: String },
|
||||
/// Change the public link `id` on the node at `path`, keeping its URL.
|
||||
/// `role` is "viewer" or "editor", or `None` to keep the current one.
|
||||
/// Replies with [`Response::PublicLink`].
|
||||
UpdatePublicLink {
|
||||
path: String,
|
||||
id: String,
|
||||
role: Option<String>,
|
||||
password: LinkChange<String>,
|
||||
expires: LinkChange<i64>,
|
||||
},
|
||||
/// The by-uid twin of [`Request::UpdatePublicLink`].
|
||||
UpdatePublicLinkByUid {
|
||||
uid: String,
|
||||
id: String,
|
||||
role: Option<String>,
|
||||
password: LinkChange<String>,
|
||||
expires: LinkChange<i64>,
|
||||
},
|
||||
/// Stop sharing the node at `path`: remove its public link, every pending
|
||||
/// invitation and every member. Each removal is attempted even if an
|
||||
/// earlier one fails. Replies with [`Response::Ok`], or an error naming
|
||||
@@ -1075,6 +1109,18 @@ pub struct PublicLinkInfo {
|
||||
pub created: i64,
|
||||
}
|
||||
|
||||
/// What [`Request::UpdatePublicLink`] does with one option of a link: its
|
||||
/// custom password or its expiry.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LinkChange<T> {
|
||||
/// Leave the option as it is.
|
||||
Keep,
|
||||
/// Remove the option: no custom password, or no expiry.
|
||||
Remove,
|
||||
/// Set the option to this value.
|
||||
Set(T),
|
||||
}
|
||||
|
||||
/// One invitation addressed to me (in [`Response::Invitations`]).
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct InvitationInfo {
|
||||
@@ -1169,6 +1215,7 @@ pub enum ActivityKind {
|
||||
Sync,
|
||||
Rename,
|
||||
Move,
|
||||
Copy,
|
||||
CreateFolder,
|
||||
Trash,
|
||||
Restore,
|
||||
@@ -1933,6 +1980,29 @@ pub struct Diagnostics {
|
||||
pub control_handler_limit: usize,
|
||||
/// Queued mutations not yet drained.
|
||||
pub pending_ops: u64,
|
||||
/// The folder-sync engine. `None` from a daemon that predates the field.
|
||||
#[serde(default)]
|
||||
pub sync: Option<SyncEngineState>,
|
||||
}
|
||||
|
||||
/// What the folder-sync engine is doing, for [`Diagnostics`].
|
||||
///
|
||||
/// The engine is one thread that runs every synced folder's passes in turn.
|
||||
/// Dead or stuck, it looked exactly like an idle one: workers idle, nothing
|
||||
/// queued, nothing in the journal (`docs/BUGS.md` B259).
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct SyncEngineState {
|
||||
/// Whether the engine thread is running. Once it has ended, nothing syncs
|
||||
/// until the daemon restarts.
|
||||
pub running: bool,
|
||||
/// The synced folder whose pass is running, if one is.
|
||||
pub pass_folder: Option<i64>,
|
||||
/// How long that pass has been running.
|
||||
pub pass_age_secs: u64,
|
||||
/// How long since that pass last reported progress.
|
||||
pub pass_idle_secs: u64,
|
||||
/// Passes that ended in a panic since the daemon started.
|
||||
pub panics: u64,
|
||||
}
|
||||
|
||||
/// The daemon's reply to a [`Request`].
|
||||
@@ -2185,9 +2255,14 @@ pub enum Response {
|
||||
entries: Vec<ShareEntry>,
|
||||
link: Option<PublicLinkInfo>,
|
||||
},
|
||||
/// A freshly created public link, carrying the shareable URL (reply to
|
||||
/// [`Request::CreatePublicLink`]).
|
||||
/// A freshly created or changed public link, carrying the shareable URL
|
||||
/// (reply to [`Request::CreatePublicLink`] and
|
||||
/// [`Request::UpdatePublicLink`]).
|
||||
PublicLink { link: PublicLinkInfo },
|
||||
/// What a folder holds on Proton Drive (reply to [`Request::FolderSize`]):
|
||||
/// the bytes on storage and the number of items below it, folders and
|
||||
/// trashed items included.
|
||||
FolderSize { size: u64, items: u64 },
|
||||
/// Invitations addressed to me (reply to [`Request::ListInvitations`]).
|
||||
Invitations { items: Vec<InvitationInfo> },
|
||||
/// Saved public links (reply to [`Request::ListBookmarks`]).
|
||||
@@ -2687,6 +2762,10 @@ mod tests {
|
||||
path: "a/b.txt".into(),
|
||||
new_parent: "d".into(),
|
||||
},
|
||||
Request::Copy {
|
||||
path: "a/b.txt".into(),
|
||||
new_parent: "d".into(),
|
||||
},
|
||||
Request::Delete {
|
||||
path: "a/b.txt".into(),
|
||||
},
|
||||
@@ -3158,6 +3237,7 @@ mod tests {
|
||||
id: 3,
|
||||
paused: true,
|
||||
},
|
||||
Request::ResetSyncFolder { id: 3 },
|
||||
Request::SetBandwidthLimits {
|
||||
upload: 1_000_000,
|
||||
download: 0,
|
||||
@@ -3247,6 +3327,24 @@ mod tests {
|
||||
uid: "vol~link".into(),
|
||||
id: "url-1".into(),
|
||||
},
|
||||
Request::UpdatePublicLink {
|
||||
path: "a/b".into(),
|
||||
id: "url-1".into(),
|
||||
role: Some("editor".into()),
|
||||
password: LinkChange::Set("pw".into()),
|
||||
expires: LinkChange::Remove,
|
||||
},
|
||||
Request::UpdatePublicLinkByUid {
|
||||
uid: "vol~link".into(),
|
||||
id: "url-1".into(),
|
||||
role: None,
|
||||
password: LinkChange::Keep,
|
||||
expires: LinkChange::Set(1_700_000_000),
|
||||
},
|
||||
Request::FolderSize { path: "a".into() },
|
||||
Request::FolderSizeByUid {
|
||||
uid: "vol~link".into(),
|
||||
},
|
||||
Request::ListSharedByMe,
|
||||
Request::ListActivity { limit: 100 },
|
||||
Request::ListSharedWithMe,
|
||||
@@ -3621,6 +3719,16 @@ mod tests {
|
||||
},
|
||||
"RemovePublicLinkByUid",
|
||||
),
|
||||
(
|
||||
Request::UpdatePublicLinkByUid {
|
||||
uid: "vol~link".into(),
|
||||
id: "link".into(),
|
||||
role: None,
|
||||
password: LinkChange::Keep,
|
||||
expires: LinkChange::Keep,
|
||||
},
|
||||
"UpdatePublicLinkByUid",
|
||||
),
|
||||
(
|
||||
Request::StopSharingByUid {
|
||||
uid: "vol~link".into(),
|
||||
@@ -3646,6 +3754,7 @@ mod tests {
|
||||
| Request::RemoveShareEntryByUid { uid, .. }
|
||||
| Request::CreatePublicLinkByUid { uid, .. }
|
||||
| Request::RemovePublicLinkByUid { uid, .. }
|
||||
| Request::UpdatePublicLinkByUid { uid, .. }
|
||||
| Request::StopSharingByUid { uid } => uid,
|
||||
other => panic!("decoded into the wrong dispatch variant: {other:?}"),
|
||||
};
|
||||
@@ -3654,6 +3763,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_update_names_what_it_keeps_removes_and_sets() {
|
||||
let request = Request::UpdatePublicLink {
|
||||
path: "a/b".into(),
|
||||
id: "url-1".into(),
|
||||
role: None,
|
||||
password: LinkChange::Keep,
|
||||
expires: LinkChange::Set(1_700_000_000),
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_string(&request).unwrap(),
|
||||
r#"{"UpdatePublicLink":{"path":"a/b","id":"url-1","role":null,"password":"Keep","expires":{"Set":1700000000}}}"#
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&LinkChange::<String>::Remove).unwrap(),
|
||||
r#""Remove""#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_file_accepts_a_request_from_a_client_predating_the_uid_field() {
|
||||
let legacy = r#"{"OpenFile":{"path":"Documents/ONBOARDING.md"}}"#;
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
//! Trace the key chain that opens My Files, for `pdfs diagnose`.
|
||||
//!
|
||||
//! The daemon only says "missing key" when the My Files share does not open
|
||||
//! (#40). This walks the chain the other way and names the link that breaks:
|
||||
//! the share's passphrase is encrypted to an address key, whose token is
|
||||
//! encrypted to an account key, which opens with the stored passphrase. Every
|
||||
//! finding holds key IDs and flags only, never key material, so the output is
|
||||
//! safe to post in an issue.
|
||||
|
||||
use proton_sdk::account::{AddressKeyReport, KeyReport, UserKeyReport};
|
||||
use proton_sdk::ids::AddressId;
|
||||
|
||||
use crate::auth;
|
||||
use crate::error::Result;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Level {
|
||||
Ok,
|
||||
Warn,
|
||||
Fail,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Finding {
|
||||
pub level: Level,
|
||||
pub label: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// The address and recipients of the My Files share passphrase, or why they
|
||||
/// could not be read.
|
||||
pub type ShareRecipients = std::result::Result<(AddressId, Vec<Option<String>>), String>;
|
||||
|
||||
/// Resume the stored session and ask Proton for the account's keys and the
|
||||
/// My Files share. Fails when there is no session, the keys are locked, or
|
||||
/// Proton cannot be reached; reading the share is allowed to fail on its own.
|
||||
///
|
||||
/// The session's tokens are the daemon's too. A refresh here spends the
|
||||
/// refresh token the daemon holds, which it recovers from by resuming the
|
||||
/// stored session (see [`auth::stored_session_is_newer`]).
|
||||
pub async fn run() -> Result<(KeyReport, ShareRecipients)> {
|
||||
let (client, _session) = auth::resume_client().await?;
|
||||
let report = client.account().key_report().await?;
|
||||
let share = client
|
||||
.my_files_share_recipients()
|
||||
.await
|
||||
.map_err(|e| e.to_string());
|
||||
Ok((report, share))
|
||||
}
|
||||
|
||||
/// One finding per key, then one for the My Files share.
|
||||
pub fn findings(report: &KeyReport, share: &ShareRecipients) -> Vec<Finding> {
|
||||
let mut findings = Vec::new();
|
||||
for key in &report.user_keys {
|
||||
findings.push(user_key_finding(key));
|
||||
}
|
||||
for address in &report.addresses {
|
||||
for key in &address.keys {
|
||||
findings.push(address_key_finding(report, &address.email, key));
|
||||
}
|
||||
}
|
||||
findings.push(share_finding(report, share));
|
||||
findings
|
||||
}
|
||||
|
||||
fn user_key_finding(key: &UserKeyReport) -> Finding {
|
||||
let label = format!("account key {}", short(key.id.as_str()));
|
||||
let (level, detail) = match (key.active, key.unlocks) {
|
||||
(true, true) => (Level::Ok, "opens".to_string()),
|
||||
(true, false) => (
|
||||
Level::Fail,
|
||||
format!(
|
||||
"does not open: {}",
|
||||
key.error.as_deref().unwrap_or("unknown")
|
||||
),
|
||||
),
|
||||
(false, true) => (Level::Ok, "inactive, opens with your password".to_string()),
|
||||
(false, false) => (Level::Ok, "inactive, does not open".to_string()),
|
||||
};
|
||||
Finding {
|
||||
level,
|
||||
label,
|
||||
detail: with_primary(key.primary, detail),
|
||||
}
|
||||
}
|
||||
|
||||
fn address_key_finding(report: &KeyReport, email: &str, key: &AddressKeyReport) -> Finding {
|
||||
let label = format!("{email} key {}", short(key.id.as_str()));
|
||||
let (level, detail) = if !key.active {
|
||||
(Level::Ok, "inactive, not used".to_string())
|
||||
} else if key.unlocks {
|
||||
(Level::Ok, "opens".to_string())
|
||||
} else {
|
||||
// A non-primary key matters only for what was encrypted to it; the
|
||||
// share finding says whether My Files is.
|
||||
let level = if key.primary {
|
||||
Level::Fail
|
||||
} else {
|
||||
Level::Warn
|
||||
};
|
||||
(level, format!("does not open: {}", why_closed(report, key)))
|
||||
};
|
||||
Finding {
|
||||
level,
|
||||
label,
|
||||
detail: with_primary(key.primary, detail),
|
||||
}
|
||||
}
|
||||
|
||||
/// Why an active address key does not open, named by the account key its
|
||||
/// token was meant for when the token has one.
|
||||
fn why_closed(report: &KeyReport, key: &AddressKeyReport) -> String {
|
||||
let error = key.error.as_deref().unwrap_or("unknown");
|
||||
let Some(recipients) = &key.token_recipients else {
|
||||
return error.to_string();
|
||||
};
|
||||
let named: Vec<&str> = recipients.iter().flatten().map(String::as_str).collect();
|
||||
if let Some(user_key) = named.iter().find_map(|r| report.user_key_named(r)) {
|
||||
let id = short(user_key.id.as_str());
|
||||
return match (user_key.active, user_key.unlocks) {
|
||||
(false, true) => {
|
||||
format!(
|
||||
"its token is for inactive account key {id}, which opens with your password"
|
||||
)
|
||||
}
|
||||
(false, false) => {
|
||||
format!("its token is for inactive account key {id}, which does not open either")
|
||||
}
|
||||
(true, false) => format!("its token is for account key {id}, which does not open"),
|
||||
// The token opened; the key itself refused its passphrase.
|
||||
(true, true) => error.to_string(),
|
||||
};
|
||||
}
|
||||
match named.first() {
|
||||
Some(recipient) => {
|
||||
format!("its token is for key {recipient}, which is none of your account keys")
|
||||
}
|
||||
None if recipients.is_empty() => error.to_string(),
|
||||
None => "its token does not name the key it is for".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn share_finding(report: &KeyReport, share: &ShareRecipients) -> Finding {
|
||||
let label = "My Files share".to_string();
|
||||
let (level, detail) = match share {
|
||||
Err(e) => (Level::Warn, format!("could not be read: {e}")),
|
||||
Ok((address_id, recipients)) => {
|
||||
let named: Vec<&str> = recipients.iter().flatten().map(String::as_str).collect();
|
||||
match named.iter().find_map(|r| report.address_key_named(r)) {
|
||||
Some((address, key)) => {
|
||||
let name = format!("{} key {}", address.email, short(key.id.as_str()));
|
||||
if key.unlocks {
|
||||
(Level::Ok, format!("encrypted to {name}, which opens"))
|
||||
} else {
|
||||
(
|
||||
Level::Fail,
|
||||
format!("encrypted to {name}, which does not open"),
|
||||
)
|
||||
}
|
||||
}
|
||||
None => (
|
||||
Level::Fail,
|
||||
match named.first() {
|
||||
Some(recipient) => format!(
|
||||
"encrypted to key {recipient}, which is none of the keys of address {address_id}"
|
||||
),
|
||||
None => "its passphrase does not name the key it is for".to_string(),
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
};
|
||||
Finding {
|
||||
level,
|
||||
label,
|
||||
detail,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_primary(primary: bool, detail: String) -> String {
|
||||
if primary {
|
||||
format!("primary, {detail}")
|
||||
} else {
|
||||
detail
|
||||
}
|
||||
}
|
||||
|
||||
/// The start of a Proton key ID: enough to tell keys apart and to find them
|
||||
/// in the daemon log, which prints the whole ID.
|
||||
fn short(id: &str) -> String {
|
||||
match id.char_indices().nth(8) {
|
||||
Some((end, _)) => format!("{}…", &id[..end]),
|
||||
None => id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proton_sdk::account::AddressReport;
|
||||
use proton_sdk::ids::{AddressKeyId, UserKeyId};
|
||||
|
||||
fn user_key(id: &str, active: bool, unlocks: bool, pgp: &str) -> UserKeyReport {
|
||||
UserKeyReport {
|
||||
id: UserKeyId::new(id),
|
||||
primary: active,
|
||||
active,
|
||||
unlocks,
|
||||
error: (!unlocks).then(|| "unlock: wrong passphrase".to_string()),
|
||||
key_ids: vec![pgp.to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
fn address_key(id: &str, primary: bool, token_for: &str, pgp: &str) -> AddressKeyReport {
|
||||
AddressKeyReport {
|
||||
id: AddressKeyId::new(id),
|
||||
primary,
|
||||
active: true,
|
||||
token_recipients: Some(vec![Some(token_for.to_string())]),
|
||||
unlocks: token_for == "u1",
|
||||
error: (token_for != "u1").then(|| "token: failed to decrypt: missing key".to_string()),
|
||||
key_ids: vec![pgp.to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
/// The account in #40, as far as its log shows: the primary address key
|
||||
/// opens, an older one does not, and My Files is encrypted to the older one.
|
||||
fn report(stranded_token_for: &str) -> KeyReport {
|
||||
KeyReport {
|
||||
user_keys: vec![
|
||||
user_key("UserKeyOne-123", true, true, "u1"),
|
||||
user_key("UserKeyTwo-456", false, true, "u2"),
|
||||
],
|
||||
addresses: vec![AddressReport {
|
||||
id: AddressId::new("address-1"),
|
||||
email: "user@proton.me".to_string(),
|
||||
keys: vec![
|
||||
address_key("AddrKeyNew-789", true, "u1", "a1"),
|
||||
address_key("AddrKeyOld-000", false, stranded_token_for, "a2"),
|
||||
],
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stranded_address_key_is_traced_to_the_inactive_account_key() {
|
||||
let report = report("u2");
|
||||
let share = Ok((AddressId::new("address-1"), vec![Some("a2".to_string())]));
|
||||
let findings = findings(&report, &share);
|
||||
|
||||
assert_eq!(findings[0].detail, "primary, opens");
|
||||
assert_eq!(findings[1].detail, "inactive, opens with your password");
|
||||
assert_eq!(findings[2].level, Level::Ok);
|
||||
assert_eq!(findings[3].level, Level::Warn);
|
||||
assert_eq!(findings[3].label, "user@proton.me key AddrKeyO…");
|
||||
assert_eq!(
|
||||
findings[3].detail,
|
||||
"does not open: its token is for inactive account key UserKeyT…, which opens with your password"
|
||||
);
|
||||
assert_eq!(findings[4].level, Level::Fail);
|
||||
assert_eq!(
|
||||
findings[4].detail,
|
||||
"encrypted to user@proton.me key AddrKeyO…, which does not open"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_for_a_key_the_account_does_not_list_says_so() {
|
||||
let report = report("ffff");
|
||||
let detail = &findings(&report, &Err("offline".to_string()))[3].detail;
|
||||
assert_eq!(
|
||||
detail,
|
||||
"does not open: its token is for key ffff, which is none of your account keys"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_share_that_opens_and_one_that_cannot_be_read() {
|
||||
let report = report("u2");
|
||||
let open = Ok((AddressId::new("address-1"), vec![Some("a1".to_string())]));
|
||||
let last = findings(&report, &open).pop().unwrap();
|
||||
assert_eq!(last.level, Level::Ok);
|
||||
assert_eq!(
|
||||
last.detail,
|
||||
"encrypted to user@proton.me key AddrKeyN…, which opens"
|
||||
);
|
||||
|
||||
let last = findings(&report, &Err("offline".to_string()))
|
||||
.pop()
|
||||
.unwrap();
|
||||
assert_eq!(last.level, Level::Warn);
|
||||
assert_eq!(last.detail, "could not be read: offline");
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub mod db;
|
||||
pub mod error;
|
||||
pub mod exiftime;
|
||||
pub mod kernelkey;
|
||||
pub mod keycheck;
|
||||
pub mod localindex;
|
||||
pub mod menu;
|
||||
pub mod mounts;
|
||||
@@ -27,6 +28,7 @@ pub mod syncignore;
|
||||
pub mod syncroot;
|
||||
pub mod takeout;
|
||||
pub mod tray;
|
||||
pub mod update;
|
||||
|
||||
pub use access::{Access, access_for, perm_bits};
|
||||
pub use error::{CoreError, CoreResult, Error, Result};
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! The opt-in check for a newer release ([`crate::config::AppConfig::update_check`]).
|
||||
//!
|
||||
//! Only the decisions live here: what GitHub's answer says, which version is
|
||||
//! newer, when to ask again, and whether a package manager already owns
|
||||
//! updates. The desktop app does the request itself.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
/// GitHub's latest published release. Drafts and pre-releases never show up
|
||||
/// here, so a tagged release candidate does not reach anyone.
|
||||
pub const RELEASES_LATEST_API: &str =
|
||||
"https://api.github.com/repos/narrrl/proton-drive-linux/releases/latest";
|
||||
|
||||
/// How often the app asks, across restarts.
|
||||
pub const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
/// Installed next to the binaries' `share/` by packages whose package manager
|
||||
/// brings new releases (COPR, the AUR, Nix). The release `.deb`, `.rpm` and
|
||||
/// `.tar.gz` do not ship it, since nothing updates them.
|
||||
pub const MANAGED_MARKER: &str = "share/proton-drive-linux/managed-updates";
|
||||
|
||||
/// The version of the release in a `/releases/latest` answer, without the
|
||||
/// tag's `v`. `None` for an answer that does not parse or a tag that is not
|
||||
/// `X.Y.Z`, so a changed API can never claim an update.
|
||||
pub fn latest_version(body: &[u8]) -> Option<String> {
|
||||
let json: serde_json::Value = serde_json::from_slice(body).ok()?;
|
||||
let tag = json.get("tag_name")?.as_str()?;
|
||||
let version = tag.strip_prefix('v').unwrap_or(tag);
|
||||
parse(version).map(|_| version.to_owned())
|
||||
}
|
||||
|
||||
/// Whether `version` is a later release than `current`. Anything that is not
|
||||
/// `X.Y.Z` on either side is not.
|
||||
pub fn is_newer(version: &str, current: &str) -> bool {
|
||||
matches!((parse(version), parse(current)), (Some(v), Some(c)) if v > c)
|
||||
}
|
||||
|
||||
/// The release page of `version` on GitHub.
|
||||
pub fn release_url(version: &str) -> String {
|
||||
format!("https://github.com/narrrl/proton-drive-linux/releases/tag/v{version}")
|
||||
}
|
||||
|
||||
/// `X.Y.Z` as numbers, so `3.10.0` sorts after `3.9.0`.
|
||||
fn parse(version: &str) -> Option<(u64, u64, u64)> {
|
||||
let mut parts = version.split('.').map(|p| p.parse::<u64>().ok());
|
||||
let triple = (parts.next()??, parts.next()??, parts.next()??);
|
||||
parts.next().is_none().then_some(triple)
|
||||
}
|
||||
|
||||
/// How long until the next check is due, given the last one in Unix seconds.
|
||||
/// Zero when it is due now. A clock set back past the last check counts as
|
||||
/// due, so a wrong clock cannot stop checks.
|
||||
pub fn next_check_in(last_checked: Option<i64>, now: i64) -> Duration {
|
||||
let interval = CHECK_INTERVAL.as_secs() as i64;
|
||||
match last_checked {
|
||||
Some(last) if (0..interval).contains(&(now - last)) => {
|
||||
Duration::from_secs((interval - (now - last)) as u64)
|
||||
}
|
||||
_ => Duration::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the binary at `exe` was installed by a package manager that updates
|
||||
/// it, which [`MANAGED_MARKER`] next to its `bin/` says.
|
||||
pub fn updates_managed(exe: &Path) -> bool {
|
||||
exe.parent()
|
||||
.and_then(Path::parent)
|
||||
.is_some_and(|prefix| prefix.join(MANAGED_MARKER).exists())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn latest(tag: &str) -> Vec<u8> {
|
||||
format!(
|
||||
r#"{{"tag_name":"{tag}","html_url":"https://github.com/narrrl/proton-drive-linux/releases/tag/{tag}","draft":false}}"#
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_latest_release_is_read_without_its_v() {
|
||||
assert_eq!(latest_version(&latest("v3.6.0")).as_deref(), Some("3.6.0"));
|
||||
assert_eq!(latest_version(&latest("3.6.0")).as_deref(), Some("3.6.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answer_that_does_not_parse_has_no_version() {
|
||||
assert_eq!(latest_version(b"not json"), None);
|
||||
assert_eq!(latest_version(b"{}"), None);
|
||||
assert_eq!(latest_version(br#"{"tag_name":7}"#), None);
|
||||
assert_eq!(latest_version(&latest("nightly")), None);
|
||||
assert_eq!(latest_version(&latest("v4.0.0-rc1")), None);
|
||||
assert_eq!(latest_version(&latest("v4.0")), None);
|
||||
assert_eq!(latest_version(&latest("v4.0.0.1")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_later_release_is_newer() {
|
||||
assert!(is_newer("3.6.0", "3.5.0"));
|
||||
assert!(!is_newer("3.5.0", "3.5.0"));
|
||||
assert!(!is_newer("3.4.1", "3.5.0"));
|
||||
assert!(!is_newer("2.99.99", "3.0.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_compare_as_numbers() {
|
||||
assert!(is_newer("3.10.0", "3.9.0"));
|
||||
assert!(is_newer("3.5.10", "3.5.9"));
|
||||
assert!(is_newer("4.0.0", "3.99.99"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_version_that_is_not_x_y_z_is_never_newer() {
|
||||
assert!(!is_newer("4.0.0", "3.5.0-dev"));
|
||||
assert!(!is_newer("garbage", "3.5.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_release_page_uses_the_tag() {
|
||||
assert_eq!(
|
||||
release_url("3.6.0"),
|
||||
"https://github.com/narrrl/proton-drive-linux/releases/tag/v3.6.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_check_is_due_once_a_day() {
|
||||
let day = CHECK_INTERVAL.as_secs() as i64;
|
||||
assert_eq!(next_check_in(None, 1_000), Duration::ZERO);
|
||||
assert_eq!(next_check_in(Some(1_000), 1_000), CHECK_INTERVAL);
|
||||
assert_eq!(
|
||||
next_check_in(Some(1_000), 1_000 + day - 1),
|
||||
Duration::from_secs(1)
|
||||
);
|
||||
assert_eq!(next_check_in(Some(1_000), 1_000 + day), Duration::ZERO);
|
||||
assert_eq!(
|
||||
next_check_in(Some(1_000), 999),
|
||||
Duration::ZERO,
|
||||
"a clock set back must not stop checks"
|
||||
);
|
||||
}
|
||||
|
||||
/// A unique temp directory removed on drop; avoids a dev-dependency, as in
|
||||
/// [`crate::syncignore`]'s tests.
|
||||
struct TempDir(std::path::PathBuf);
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static N: AtomicU64 = AtomicU64::new(0);
|
||||
let p = std::env::temp_dir().join(format!(
|
||||
"pdfs-update-{}-{}",
|
||||
std::process::id(),
|
||||
N.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
TempDir(p)
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_marker_next_to_bin_means_a_package_manager_updates() {
|
||||
let prefix = TempDir::new();
|
||||
let exe = prefix.0.join("bin/pdfs-app");
|
||||
assert!(!updates_managed(&exe));
|
||||
let marker = prefix.0.join(MANAGED_MARKER);
|
||||
std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
|
||||
std::fs::write(&marker, "").unwrap();
|
||||
assert!(updates_managed(&exe));
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,17 @@ use super::{Core, count_noun, human_bytes, human_duration, parse_uid};
|
||||
|
||||
const MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024;
|
||||
const CONTROL_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// Answer a [`CtlRequest::FolderSize`].
|
||||
fn folder_size_reply(size: CoreResult<proton_drive_rs::FolderSize>) -> CtlResponse {
|
||||
match size {
|
||||
Ok(size) => CtlResponse::FolderSize {
|
||||
size: size.size,
|
||||
items: size.descendants,
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Answer a [`CtlRequest::StopSharing`]: log it, and turn a partial removal
|
||||
/// into an error that names what is still shared.
|
||||
fn stop_sharing_reply(
|
||||
@@ -377,6 +388,7 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
|
||||
| R::RemoveSyncFolder { .. }
|
||||
| R::SetSyncFolderMode { .. }
|
||||
| R::SetSyncFolderPaused { .. }
|
||||
| R::ResetSyncFolder { .. }
|
||||
| R::RestoreSyncFolders { .. }
|
||||
| R::RestoreDeviceFolders { .. } => &[Topic::Locations, Topic::Devices],
|
||||
R::RetryPendingOp { .. } => &[Topic::Queue],
|
||||
@@ -392,6 +404,8 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
|
||||
| R::CreatePublicLinkByUid { .. }
|
||||
| R::RemovePublicLink { .. }
|
||||
| R::RemovePublicLinkByUid { .. }
|
||||
| R::UpdatePublicLink { .. }
|
||||
| R::UpdatePublicLinkByUid { .. }
|
||||
| R::StopSharing { .. }
|
||||
| R::StopSharingByUid { .. } => &[Topic::Shares, Topic::Files],
|
||||
R::LeaveShared { .. }
|
||||
@@ -1063,6 +1077,36 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
(Err(e), _) | (_, Err(e)) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::Copy { path, new_parent }) => {
|
||||
match (
|
||||
rel_to_mount(mountpoint, &path),
|
||||
rel_to_mount(mountpoint, &new_parent),
|
||||
) {
|
||||
(Ok(rel), Ok(parent_rel)) => match core.copy_to(&rel, &parent_rel) {
|
||||
Ok(name) => {
|
||||
let dest = if new_parent.is_empty() {
|
||||
"My files".to_string()
|
||||
} else {
|
||||
new_parent.clone()
|
||||
};
|
||||
core.log_activity(ActivityKind::Copy, &name, format!("to {dest}"), true);
|
||||
CtlResponse::Ok {
|
||||
message: format!("copied to {name}"),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
core.log_activity(ActivityKind::Copy, &path, &e, false);
|
||||
CtlResponse::error(e)
|
||||
}
|
||||
},
|
||||
(Err(e), _) | (_, Err(e)) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::FolderSize { path }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => folder_size_reply(core.folder_size(&rel)),
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::FolderSizeByUid { uid }) => folder_size_reply(core.folder_size_by_uid(&uid)),
|
||||
Ok(CtlRequest::Delete { path }) => match route_node(core, mountpoint, &path) {
|
||||
Ok((core, rel)) => match core.delete(&rel) {
|
||||
Ok(name) => {
|
||||
@@ -1389,6 +1433,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::ResetSyncFolder { id }) => match core.reset_sync_folder(id) {
|
||||
Ok(message) => {
|
||||
core.log_activity(ActivityKind::Sync, &message, "", true);
|
||||
CtlResponse::Ok { message }
|
||||
}
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::SyncNow { id }) => {
|
||||
core.sync_now(id);
|
||||
CtlResponse::Ok {
|
||||
@@ -1630,6 +1681,31 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
CtlResponse::error(e)
|
||||
}
|
||||
},
|
||||
Ok(CtlRequest::UpdatePublicLink {
|
||||
path,
|
||||
id,
|
||||
role,
|
||||
password,
|
||||
expires,
|
||||
}) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => {
|
||||
match core.update_public_link(&rel, &id, role.as_deref(), password, expires) {
|
||||
Ok(link) => CtlResponse::PublicLink { link },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::UpdatePublicLinkByUid {
|
||||
uid,
|
||||
id,
|
||||
role,
|
||||
password,
|
||||
expires,
|
||||
}) => match core.update_public_link_by_uid(&uid, &id, role.as_deref(), password, expires) {
|
||||
Ok(link) => CtlResponse::PublicLink { link },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::RemovePublicLink { path, id }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.remove_public_link(&rel, &id) {
|
||||
Ok(()) => {
|
||||
|
||||
@@ -989,9 +989,15 @@ impl Core {
|
||||
.db
|
||||
.sync_entries(id)
|
||||
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?;
|
||||
if let Some(reason) =
|
||||
sync::mirror_tree_unsynced(&local, &baseline, &self.ignore_rules(&local))
|
||||
{
|
||||
let rules = self.ignore_rules(&local);
|
||||
// Emptied before any pass saw it, the folder is still `idle`, and
|
||||
// the check below would refuse the switch for good. The push pass
|
||||
// of a queued switch forgets those paths instead (`docs/BUGS.md`
|
||||
// B255).
|
||||
if sync::mirror_tree_gone(&local, &baseline, &rules) {
|
||||
return Err(SwitchBlocked::NotNow);
|
||||
}
|
||||
if let Some(reason) = sync::mirror_tree_unsynced(&local, &baseline, &rules) {
|
||||
return Err(SwitchBlocked::Failed(format!(
|
||||
"going online-only would delete what is only on this computer: {reason}"
|
||||
)));
|
||||
@@ -1080,6 +1086,50 @@ impl Core {
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget what a mirror folder synced before, so its next pass compares the
|
||||
/// two sides as on the first sync: what one side has alone is copied to the
|
||||
/// other, and a file that differs is kept twice as a conflict. Nothing is
|
||||
/// deleted on either side.
|
||||
///
|
||||
/// This is the way out for a folder whose passes refuse to run because its
|
||||
/// whole local copy, or its whole copy on Drive, is gone (#46). The baseline
|
||||
/// is what makes that read as "delete everything", and it is what this
|
||||
/// drops. A queued switch to on-demand is dropped with it: the push pass
|
||||
/// that switch runs reads an unrecorded local file as new and would upload
|
||||
/// a second copy of every file Drive already has.
|
||||
pub(crate) fn reset_sync_folder(&self, id: i64) -> CoreResult<String> {
|
||||
let lock = self.sync_lock(id);
|
||||
let Some(_guard) = lock.try_lock() else {
|
||||
return Err(CoreError::conflict(
|
||||
"this folder is syncing; try again when the pass ends",
|
||||
));
|
||||
};
|
||||
let folder = self
|
||||
.db
|
||||
.sync_folder_get(id)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
||||
if folder.mode != "mirror" {
|
||||
return Err(CoreError::invalid(
|
||||
"an online-only folder keeps nothing on this computer to sync from scratch",
|
||||
));
|
||||
}
|
||||
self.db
|
||||
.sync_entries_clear(id)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
if folder.pending_mode.as_deref() == Some("ondemand") {
|
||||
self.db
|
||||
.sync_folder_set_pending_mode(id, None)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
}
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
||||
info!(id, path = %folder.local_path, "sync folder reset");
|
||||
Ok(format!(
|
||||
"{} will sync from scratch",
|
||||
base_name(&folder.local_path)
|
||||
))
|
||||
}
|
||||
|
||||
/// Spawn a secondary FUSE session rooted at `root` over `local` from a
|
||||
/// [`Core::fork_state`] sibling. Clears any stale kernel mount first (a crashed run can leave
|
||||
/// one, which would fail the fresh mount with EBUSY).
|
||||
|
||||
@@ -163,6 +163,7 @@ impl Core {
|
||||
control_handlers: super::control::active_handlers(),
|
||||
control_handler_limit: super::control::handler_limit(),
|
||||
pending_ops: self.pending_ops_quickly(),
|
||||
sync: Some(self.sync_engine.report()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ use proton_drive_rs::proton_sdk::account::Quota;
|
||||
use proton_drive_rs::proton_sdk::error::Result;
|
||||
use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid};
|
||||
use proton_drive_rs::{
|
||||
Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem, ProtonDriveClient,
|
||||
RevisionReader, Thumbnail,
|
||||
AvailableNameTarget, Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem,
|
||||
ProtonDriveClient, RevisionReader, Thumbnail,
|
||||
};
|
||||
|
||||
/// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`].
|
||||
@@ -138,6 +138,22 @@ pub(crate) trait DriveApi: Send + Sync {
|
||||
|
||||
async fn move_node(&self, uid: &NodeUid, new_parent: &NodeUid) -> Result<()>;
|
||||
|
||||
/// See [`ProtonDriveClient::copy_node`].
|
||||
async fn copy_node(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
new_parent: &NodeUid,
|
||||
name: Option<&str>,
|
||||
) -> Result<NodeUid>;
|
||||
|
||||
/// See [`ProtonDriveClient::get_available_name`].
|
||||
async fn get_available_name(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
target: AvailableNameTarget,
|
||||
) -> Result<String>;
|
||||
|
||||
fn move_nodes_streaming<'a>(
|
||||
&'a self,
|
||||
items: Vec<NodeMoveItem>,
|
||||
@@ -448,6 +464,24 @@ impl DriveApi for ProtonDriveClient {
|
||||
ProtonDriveClient::move_node(self, uid, new_parent).await
|
||||
}
|
||||
|
||||
async fn copy_node(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
new_parent: &NodeUid,
|
||||
name: Option<&str>,
|
||||
) -> Result<NodeUid> {
|
||||
ProtonDriveClient::copy_node(self, uid, new_parent, name).await
|
||||
}
|
||||
|
||||
async fn get_available_name(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
target: AvailableNameTarget,
|
||||
) -> Result<String> {
|
||||
ProtonDriveClient::get_available_name(self, parent_uid, name, target).await
|
||||
}
|
||||
|
||||
fn move_nodes_streaming<'a>(
|
||||
&'a self,
|
||||
items: Vec<NodeMoveItem>,
|
||||
|
||||
@@ -430,58 +430,10 @@ impl Filesystem for ProtonFs {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
let fh = fh.0;
|
||||
// Stage the bytes straight into the scratch file (no base download): only
|
||||
// the untouched remainder is pulled from the remote, and only at commit.
|
||||
let file = {
|
||||
let st = self.core.state();
|
||||
match st.handles.get(&fh).and_then(|&i| st.active_writes.get(&i)) {
|
||||
Some(aw) => aw.file.clone(),
|
||||
None => {
|
||||
reply.error(Errno::EBADF);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Err(e) = file.write_all_at(data, offset) {
|
||||
error!(ino = ino.0, fh, error = %e, "scratch write failed");
|
||||
// A full local disk is not a broken file: answer `ENOSPC` so the
|
||||
// application can say so (and so `cp`/editors take their own
|
||||
// out-of-space path), and free what the cache is holding — the
|
||||
// scratch file the next write goes to shares that filesystem.
|
||||
if e.raw_os_error() == Some(libc::ENOSPC) {
|
||||
self.core.cache.emergency_evict();
|
||||
reply.error(Errno::ENOSPC);
|
||||
} else {
|
||||
reply.error(Errno::EIO);
|
||||
}
|
||||
return;
|
||||
match self.stage_write(ino.0, fh.0, offset, data) {
|
||||
Ok(()) => reply.written(data.len() as u32),
|
||||
Err(error) => reply.error(error),
|
||||
}
|
||||
let new_len = {
|
||||
let mut st = self.core.state();
|
||||
let Some(aw) = st
|
||||
.handles
|
||||
.get(&fh)
|
||||
.copied()
|
||||
.and_then(|i| st.active_writes.get_mut(&i))
|
||||
else {
|
||||
reply.error(Errno::EBADF);
|
||||
return;
|
||||
};
|
||||
aw.record_write(offset, offset + data.len() as u64);
|
||||
let len = aw.len;
|
||||
st.set_size(ino.0, len);
|
||||
len
|
||||
};
|
||||
debug!(
|
||||
ino = ino.0,
|
||||
fh,
|
||||
offset,
|
||||
len = data.len(),
|
||||
new_len,
|
||||
"staged write"
|
||||
);
|
||||
reply.written(data.len() as u32);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -719,7 +671,7 @@ impl Filesystem for ProtonFs {
|
||||
reply: ReplyEmpty,
|
||||
) {
|
||||
let ino = self.tree(ino);
|
||||
let (handle, unlinked_uid) = {
|
||||
let (handle, unlinked_uid, releasing) = {
|
||||
let mut st = self.core.state();
|
||||
let unlinked_uid = release_unlinked_entry(&mut st, ino.0);
|
||||
let ino = st.handles.remove(&fh.0);
|
||||
@@ -732,7 +684,11 @@ impl Filesystem for ProtonFs {
|
||||
None
|
||||
}
|
||||
});
|
||||
(h, unlinked_uid)
|
||||
let releasing = ino.filter(|_| h.is_some());
|
||||
if let Some(ino) = releasing {
|
||||
st.releasing.insert(ino);
|
||||
}
|
||||
(h, unlinked_uid, releasing)
|
||||
};
|
||||
// Deliberately **not** handed to a worker, unlike the other slow
|
||||
// handlers here. The kernel does not wait for a `release` reply before
|
||||
@@ -749,6 +705,9 @@ impl Filesystem for ProtonFs {
|
||||
// finish it (audit F1). What is left is a rename, two fsyncs, a database
|
||||
// insert, and at most a bounded copy out of the local cache.
|
||||
self.finish_release(handle, unlinked_uid, reply);
|
||||
if let Some(ino) = releasing {
|
||||
self.core.state().releasing.remove(&ino);
|
||||
}
|
||||
}
|
||||
|
||||
fn unlink(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) {
|
||||
@@ -1016,20 +975,59 @@ impl Filesystem for ProtonFs {
|
||||
fn copy_file_range(
|
||||
&self,
|
||||
_req: &Request,
|
||||
_ino_in: INodeNo,
|
||||
ino_in: INodeNo,
|
||||
_fh_in: FileHandle,
|
||||
_offset_in: u64,
|
||||
_ino_out: INodeNo,
|
||||
_fh_out: FileHandle,
|
||||
_offset_out: u64,
|
||||
_len: u64,
|
||||
_flags: CopyFileRangeFlags,
|
||||
offset_in: u64,
|
||||
ino_out: INodeNo,
|
||||
fh_out: FileHandle,
|
||||
offset_out: u64,
|
||||
len: u64,
|
||||
flags: CopyFileRangeFlags,
|
||||
reply: ReplyWrite,
|
||||
) {
|
||||
// Also must stay ENOSYS: there is no server-side copy, and this is the
|
||||
// errno that sends coreutils (and the kernel) down the read/write path
|
||||
// instead of failing the copy outright.
|
||||
reply.error(Errno::ENOSYS);
|
||||
// Copied here when the source bytes are on this machine, as `write`
|
||||
// would stage them, minus a FUSE round trip per page: `cp` of a cached
|
||||
// 256 MiB file ran at about 100 MiB/s through read and write. Anything
|
||||
// that would have to be downloaded gets `EOPNOTSUPP`, which makes the
|
||||
// kernel copy that one call through read and write itself; `ENOSYS`
|
||||
// would turn the op off for the whole mount. There is no server-side
|
||||
// copy into a file `cp` has already created, so the copy still uploads.
|
||||
//
|
||||
// On the dispatch loop, like `write`, so it keeps its order with the
|
||||
// reads and writes of the same handle. [`COPY_RANGE_MAX`] bounds how
|
||||
// long one call holds the loop.
|
||||
let (ino_in, ino_out) = (self.tree(ino_in), self.tree(ino_out));
|
||||
if !flags.is_empty() || ino_in == ino_out {
|
||||
reply.error(Errno::EOPNOTSUPP);
|
||||
return;
|
||||
}
|
||||
if let Err(error) = self.core.require_writable(ino_out.0) {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
let bytes = match self
|
||||
.core
|
||||
.read_local(ino_in.0, offset_in, len.min(COPY_RANGE_MAX))
|
||||
{
|
||||
Ok(Some(bytes)) => bytes,
|
||||
Ok(None) => {
|
||||
debug!(ino_in = ino_in.0, offset_in, "copy range not local");
|
||||
reply.error(Errno::EOPNOTSUPP);
|
||||
return;
|
||||
}
|
||||
Err(error) => {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if bytes.is_empty() {
|
||||
reply.written(0);
|
||||
return;
|
||||
}
|
||||
match self.stage_write(ino_out.0, fh_out.0, offset_out, &bytes) {
|
||||
Ok(()) => reply.written(bytes.len() as u32),
|
||||
Err(error) => reply.error(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1057,6 +1055,11 @@ pub struct ProtonFs {
|
||||
/// worker busy indefinitely.
|
||||
const OPEN_BASE_ATTEMPTS: u32 = 3;
|
||||
|
||||
/// The most one `copy_file_range` call copies; the caller loops for the rest.
|
||||
/// A local copy of this much takes milliseconds, which is how long it holds
|
||||
/// the dispatch loop.
|
||||
const COPY_RANGE_MAX: u64 = 16 * 1024 * 1024;
|
||||
|
||||
/// One directory's entries as `readdir` reports them: `.` and `..` first, then
|
||||
/// the interned children in `children` order.
|
||||
///
|
||||
@@ -1641,6 +1644,48 @@ impl ProtonFs {
|
||||
}
|
||||
|
||||
/// The body of [`Filesystem::create`], run off the dispatch loop.
|
||||
/// Stage `data` at `offset` of the write handle `fh` on `ino`, straight into
|
||||
/// its scratch file (no base download): only the untouched remainder is
|
||||
/// pulled from the remote, and only at commit.
|
||||
fn stage_write(&self, ino: u64, fh: u64, offset: u64, data: &[u8]) -> Result<(), Errno> {
|
||||
let file = {
|
||||
let st = self.core.state();
|
||||
match st.handles.get(&fh).and_then(|&i| st.active_writes.get(&i)) {
|
||||
Some(aw) => aw.file.clone(),
|
||||
None => return Err(Errno::EBADF),
|
||||
}
|
||||
};
|
||||
if let Err(e) = file.write_all_at(data, offset) {
|
||||
error!(ino, fh, error = %e, "scratch write failed");
|
||||
// A full local disk is not a broken file: answer `ENOSPC` so the
|
||||
// application can say so (and so `cp`/editors take their own
|
||||
// out-of-space path), and free what the cache is holding — the
|
||||
// scratch file the next write goes to shares that filesystem.
|
||||
if e.raw_os_error() == Some(libc::ENOSPC) {
|
||||
self.core.cache.emergency_evict();
|
||||
return Err(Errno::ENOSPC);
|
||||
}
|
||||
return Err(Errno::EIO);
|
||||
}
|
||||
let new_len = {
|
||||
let mut st = self.core.state();
|
||||
let Some(aw) = st
|
||||
.handles
|
||||
.get(&fh)
|
||||
.copied()
|
||||
.and_then(|i| st.active_writes.get_mut(&i))
|
||||
else {
|
||||
return Err(Errno::EBADF);
|
||||
};
|
||||
aw.record_write(offset, offset + data.len() as u64);
|
||||
let len = aw.len;
|
||||
st.set_size(ino, len);
|
||||
len
|
||||
};
|
||||
debug!(ino, fh, offset, len = data.len(), new_len, "staged write");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn serve_create(&self, parent: u64, name: &str, reply: ReplyCreate) {
|
||||
if let Err(error) = self.core.require_writable(parent) {
|
||||
reply.error(error);
|
||||
|
||||
+135
-3
@@ -74,8 +74,8 @@ use proton_drive_rs::proton_sdk::api::ResponseCode;
|
||||
use proton_drive_rs::proton_sdk::error::ProtonError;
|
||||
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, ShareId, VolumeId};
|
||||
use proton_drive_rs::{
|
||||
DriveEvent, DriveEventScopeId, MemberRole, Node, NodeKind, NodeMoveItem, ProtonDriveClient,
|
||||
ProtonPhotosClient, SharedWithMeItem, ThumbnailType,
|
||||
AvailableNameTarget, DriveEvent, DriveEventScopeId, MemberRole, Node, NodeKind, NodeMoveItem,
|
||||
ProtonDriveClient, ProtonPhotosClient, SharedWithMeItem, ThumbnailType,
|
||||
};
|
||||
|
||||
mod albums;
|
||||
@@ -728,6 +728,8 @@ struct Core {
|
||||
/// can say what a pass is doing rather than just "syncing". An entry exists
|
||||
/// only while that folder's reconcile pass is running.
|
||||
sync_progress: Arc<Mutex<HashMap<i64, SyncProgress>>>,
|
||||
/// Whether the folder-sync engine is alive, and which pass it is on.
|
||||
sync_engine: Arc<sync::EngineHealth>,
|
||||
/// Channel to the folder-sync engine: nudges it to
|
||||
/// reconcile a folder, reconcile everything, or re-scan its watch set.
|
||||
sync_tx: std::sync::mpsc::Sender<sync::SyncMsg>,
|
||||
@@ -5206,6 +5208,133 @@ impl Core {
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Copy a file or folder into `new_parent_rel` on Proton Drive, which copies
|
||||
/// the content there: nothing is downloaded or uploaded. Both paths are
|
||||
/// mountpoint-relative. A name already taken in the folder gets the next
|
||||
/// free ` (n)` suffix. Returns the copy's name.
|
||||
///
|
||||
/// Drive copies what it holds, so the copy is refused offline and while the
|
||||
/// mount still owes Drive part of it: a node or folder only queued locally,
|
||||
/// a file with a queued upload or unsaved writes, or — for a folder, whose
|
||||
/// queued changes could sit anywhere below it — any queued op at all.
|
||||
fn copy_to(&self, rel: &Path, new_parent_rel: &Path) -> CoreResult<String> {
|
||||
let (ino, uid) = self.resolve(rel)?;
|
||||
let (pino, new_parent_uid) = self
|
||||
.resolve_path(new_parent_rel)
|
||||
.map_err(|e| self.errno_error(e, "resolve new parent"))?;
|
||||
self.ensure_children(pino)
|
||||
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
||||
self.require_uid_writable(&new_parent_uid)
|
||||
.map_err(|error| self.errno_error(error, "copy access"))?;
|
||||
let (name, is_folder, unsaved) = {
|
||||
let st = self.state();
|
||||
let entry = st
|
||||
.entries
|
||||
.get(&ino)
|
||||
.ok_or_else(|| CoreError::not_found(format!("{} is gone", rel.display())))?;
|
||||
// Unsaved writes, or a release still queueing them, to the file or,
|
||||
// for a folder, to any file below it.
|
||||
let unsaved = st
|
||||
.active_writes
|
||||
.iter()
|
||||
.filter(|(_, w)| w.dirty)
|
||||
.map(|(&open, _)| open)
|
||||
.chain(st.releasing.iter().copied())
|
||||
.any(|open| relocate::ino_is_under(&st, open, ino));
|
||||
(entry.node.name.clone(), entry.node.is_folder(), unsaved)
|
||||
};
|
||||
// Unlike a move, a copy cannot wait in the queue: it needs the node as
|
||||
// Drive has it, so it goes now or not at all, local-first or not.
|
||||
if !self.is_online() {
|
||||
return Err(CoreError::offline());
|
||||
}
|
||||
let owed = unsaved
|
||||
|| is_local_uid(&uid)
|
||||
|| is_local_uid(&new_parent_uid)
|
||||
|| self.db.has_any_op(&uid.to_string()).unwrap_or(true)
|
||||
|| (is_folder
|
||||
&& self
|
||||
.db
|
||||
.pending_op_counts()
|
||||
.map(|counts| counts.uploads + counts.changes > 0)
|
||||
.unwrap_or(true));
|
||||
if owed {
|
||||
return Err(CoreError::conflict(format!(
|
||||
"{name} has changes that have not reached Proton Drive yet; \
|
||||
copy it once they have synced"
|
||||
)));
|
||||
}
|
||||
let target = if is_folder {
|
||||
AvailableNameTarget::Folder
|
||||
} else {
|
||||
AvailableNameTarget::File
|
||||
};
|
||||
let copy_name = self
|
||||
.rt
|
||||
.block_on(
|
||||
self.drive
|
||||
.get_available_name(&new_parent_uid, &name, target),
|
||||
)
|
||||
.map_err(|e| CoreError::from_api(&e, "copy"))?;
|
||||
let new_uid = self
|
||||
.rt
|
||||
.block_on(
|
||||
self.drive
|
||||
.copy_node(&uid, &new_parent_uid, Some(©_name)),
|
||||
)
|
||||
.map_err(|e| CoreError::from_api(&e, "copy"))?;
|
||||
let node = self
|
||||
.fetch_minted_node(&new_uid, ©_name)
|
||||
.ok_or_else(|| minted_but_unlisted(©_name))?;
|
||||
let mut st = self.state();
|
||||
let copy_ino = st.intern(pino, node);
|
||||
if let Some(kids) = st.children.get_mut(&pino)
|
||||
&& !kids.contains(©_ino)
|
||||
{
|
||||
kids.push(copy_ino);
|
||||
}
|
||||
drop(st);
|
||||
self.flush_access_changes();
|
||||
Ok(copy_name)
|
||||
}
|
||||
|
||||
/// What the folder at `rel` holds on Proton Drive: bytes on storage and the
|
||||
/// number of items below it. Drive adds it up, so nothing is listed here;
|
||||
/// trashed items count, and the bytes are encrypted ones.
|
||||
pub(crate) fn folder_size(&self, rel: &Path) -> CoreResult<proton_drive_rs::FolderSize> {
|
||||
let (ino, uid) = self.resolve(rel)?;
|
||||
let is_folder = self
|
||||
.state()
|
||||
.entries
|
||||
.get(&ino)
|
||||
.is_some_and(|entry| entry.node.is_folder());
|
||||
if !is_folder {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{} is not a folder",
|
||||
rel.display()
|
||||
)));
|
||||
}
|
||||
self.folder_size_for_uid(&uid)
|
||||
}
|
||||
|
||||
/// [`Core::folder_size`] for a folder addressed through any daemon location.
|
||||
pub(crate) fn folder_size_by_uid(&self, uid: &str) -> CoreResult<proton_drive_rs::FolderSize> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.folder_size_for_uid(&uid)
|
||||
}
|
||||
|
||||
fn folder_size_for_uid(&self, uid: &NodeUid) -> CoreResult<proton_drive_rs::FolderSize> {
|
||||
if is_local_uid(uid) {
|
||||
return Err(CoreError::conflict("the folder is not on Proton Drive yet"));
|
||||
}
|
||||
if !self.is_online() {
|
||||
return Err(CoreError::offline());
|
||||
}
|
||||
self.rt
|
||||
.block_on(self.client.get_folder_size(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "folder size"))
|
||||
}
|
||||
|
||||
/// Trash a file or folder. `rel` is mountpoint-relative. Forgets the node,
|
||||
/// evicts any cached content, and invalidates the parent listing.
|
||||
fn delete(&self, rel: &Path) -> CoreResult<String> {
|
||||
@@ -5862,6 +5991,7 @@ impl Core {
|
||||
if let Some(p) = self.sync_progress.lock().get_mut(&folder_id) {
|
||||
f(p);
|
||||
}
|
||||
self.sync_engine.moved();
|
||||
}
|
||||
|
||||
/// Set the number of items the scan expects to check, from the size of the
|
||||
@@ -7185,13 +7315,15 @@ mod size_upgrade_tests {
|
||||
fn a_listing_waits_for_the_whole_size_batch_but_not_forever() {
|
||||
let slot = Arc::new(SizeUpgrade::default());
|
||||
let worker = slot.clone();
|
||||
// Read before the spawn: the worker's sleeps can start before this
|
||||
// thread runs again, which made the bound below fail on a busy runner.
|
||||
let started = Instant::now();
|
||||
let t = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
worker.chunk_done();
|
||||
std::thread::sleep(Duration::from_millis(60));
|
||||
worker.finish();
|
||||
});
|
||||
let started = Instant::now();
|
||||
slot.wait_finished(started + Duration::from_secs(5));
|
||||
// Past the first chunk, up to the end of the batch.
|
||||
assert!(started.elapsed() >= Duration::from_millis(90));
|
||||
|
||||
@@ -507,6 +507,7 @@ pub(crate) fn mount_with(
|
||||
events: Arc::new(Default::default()),
|
||||
indexing: Arc::new(AtomicBool::new(false)),
|
||||
sync_progress: Arc::new(Mutex::new(HashMap::new())),
|
||||
sync_engine: Arc::default(),
|
||||
sync_tx,
|
||||
mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
|
||||
sync_locks: Arc::new(Mutex::new(HashMap::new())),
|
||||
|
||||
@@ -450,6 +450,10 @@ impl Core {
|
||||
}
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||
|
||||
// The reply is gone once the app has shown it; the log keeps why.
|
||||
for reason in &skipped {
|
||||
info!(reason, "restore skipped a folder");
|
||||
}
|
||||
let mut message = format!("restored {restored} folder(s)");
|
||||
if !skipped.is_empty() {
|
||||
message.push_str(&format!("; skipped: {}", skipped.join(", ")));
|
||||
|
||||
@@ -1101,6 +1101,92 @@ impl Core {
|
||||
})
|
||||
}
|
||||
|
||||
/// Bytes `[offset, offset + size)` of file `ino` as `read` would serve
|
||||
/// them, but only from this machine: an open write handle, a queued write,
|
||||
/// or the caches. `Ok(None)` the moment any of it would have to be
|
||||
/// downloaded, and for a size still provisional (B100), which only the
|
||||
/// network settles. Short or empty past the end of the file.
|
||||
///
|
||||
/// `copy_file_range` answers with this and leaves anything else to the
|
||||
/// kernel's read/write copy.
|
||||
pub(super) fn read_local(
|
||||
&self,
|
||||
ino: u64,
|
||||
offset: u64,
|
||||
size: u64,
|
||||
) -> Result<Option<Vec<u8>>, Errno> {
|
||||
let handle = self.state().active_writes.get(&ino).map(|h| {
|
||||
(
|
||||
h.file.clone(),
|
||||
h.len,
|
||||
h.uid.clone(),
|
||||
h.base_mtime,
|
||||
h.base_size,
|
||||
h.written.clone(),
|
||||
)
|
||||
});
|
||||
if let Some((file, len, uid, base_mtime, base_size, written)) = handle {
|
||||
return self.serve_open_read_local(
|
||||
&file, len, &uid, base_mtime, base_size, &written, offset, size,
|
||||
);
|
||||
}
|
||||
let (uid, mtime, fsize, provisional) = {
|
||||
let st = self.state();
|
||||
match st.entries.get(&ino) {
|
||||
Some(e) if e.node.is_file() => (
|
||||
e.uid.clone(),
|
||||
e.node.modification_time,
|
||||
node_size(&e.node),
|
||||
matches!(
|
||||
&e.node.kind,
|
||||
NodeKind::File {
|
||||
claimed_size: None,
|
||||
..
|
||||
}
|
||||
),
|
||||
),
|
||||
Some(_) => return Err(Errno::EISDIR),
|
||||
None => return Err(Errno::ENOENT),
|
||||
}
|
||||
};
|
||||
let uid = match is_local_uid(&uid) {
|
||||
true => self.landed_stand_in_uid(&uid).unwrap_or(uid),
|
||||
false => uid,
|
||||
};
|
||||
if let Some(pending) = self.pending.lock().get(&uid).cloned() {
|
||||
let m = &pending.meta;
|
||||
let file = File::open(&pending.path).map_err(|e| {
|
||||
error!(%uid, path = %pending.path.display(), error = %e, "open staged blob failed");
|
||||
Errno::EIO
|
||||
})?;
|
||||
let mut written = Intervals::default();
|
||||
for &(s, e) in &m.authored {
|
||||
written.add(s, e);
|
||||
}
|
||||
return self.serve_open_read_local(
|
||||
&Arc::new(file),
|
||||
m.len,
|
||||
&uid,
|
||||
m.base_mtime,
|
||||
m.base_size,
|
||||
&written,
|
||||
offset,
|
||||
size,
|
||||
);
|
||||
}
|
||||
// Created here and never written: an empty file.
|
||||
if is_local_uid(&uid) {
|
||||
return Ok(Some(Vec::new()));
|
||||
}
|
||||
if provisional {
|
||||
return Ok(None);
|
||||
}
|
||||
if offset >= fsize || size == 0 {
|
||||
return Ok(Some(Vec::new()));
|
||||
}
|
||||
Ok(self.cached_range(&uid, mtime, fsize, offset, size.min(fsize - offset)))
|
||||
}
|
||||
|
||||
/// The largest amount [`Core::fill_gaps_cached`] will copy out of the local
|
||||
/// caches while a `release(2)` is in progress.
|
||||
///
|
||||
|
||||
@@ -133,7 +133,7 @@ fn rename_noreplace(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
|
||||
/// Whether inode `ino` is `root` or lies under it in the tree. Bounded, so a
|
||||
/// parent cycle in corrupt data cannot hang the caller.
|
||||
fn ino_is_under(st: &State, mut ino: u64, root: u64) -> bool {
|
||||
pub(crate) fn ino_is_under(st: &State, mut ino: u64, root: u64) -> bool {
|
||||
for _ in 0..MAX_TREE_DEPTH {
|
||||
if ino == root {
|
||||
return true;
|
||||
@@ -456,6 +456,7 @@ impl Core {
|
||||
writing = st
|
||||
.active_writes
|
||||
.keys()
|
||||
.chain(&st.releasing)
|
||||
.any(|&open| ino_is_under(st, open, ino));
|
||||
});
|
||||
Ok(writing)
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
use std::path::Path;
|
||||
|
||||
use pdfs_core::control::{
|
||||
BookmarkInfo, DirEntry, InvitationInfo, PublicLinkInfo, ShareEntry, ShareEntryKind, SharedItem,
|
||||
BookmarkInfo, DirEntry, InvitationInfo, LinkChange, PublicLinkInfo, ShareEntry, ShareEntryKind,
|
||||
SharedItem,
|
||||
};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use tracing::debug;
|
||||
@@ -367,6 +368,89 @@ impl Core {
|
||||
Ok(public_link_info(link))
|
||||
}
|
||||
|
||||
/// Change the public link `id` on the node at `rel`, keeping its URL.
|
||||
pub(crate) fn update_public_link(
|
||||
&self,
|
||||
rel: &Path,
|
||||
id: &str,
|
||||
role: Option<&str>,
|
||||
password: LinkChange<String>,
|
||||
expires: LinkChange<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.update_public_link_for_uid(&uid, id, role, password, expires)
|
||||
}
|
||||
|
||||
/// [`Core::update_public_link`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn update_public_link_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
id: &str,
|
||||
role: Option<&str>,
|
||||
password: LinkChange<String>,
|
||||
expires: LinkChange<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.update_public_link_for_uid(&uid, id, role, password, expires)
|
||||
}
|
||||
|
||||
/// The SDK sets all three options on every update, so each `Keep` is
|
||||
/// filled in from the link as it stands.
|
||||
fn update_public_link_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
id: &str,
|
||||
role: Option<&str>,
|
||||
password: LinkChange<String>,
|
||||
expires: LinkChange<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(self.client.get_public_link(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "get public link"))?
|
||||
.filter(|l| l.public_link_id == id)
|
||||
.ok_or_else(|| CoreError::not_found("public link not found"))?;
|
||||
let role = match role {
|
||||
Some(role) => role_from_str(role)?,
|
||||
None => link.role,
|
||||
};
|
||||
let unchanged = role == link.role
|
||||
&& matches!(password, LinkChange::Keep)
|
||||
&& matches!(expires, LinkChange::Keep);
|
||||
if unchanged {
|
||||
return Ok(public_link_info(link));
|
||||
}
|
||||
let password = match password {
|
||||
LinkChange::Keep if link.has_custom_password => {
|
||||
// Sending `None` here would quietly remove the password.
|
||||
Some(link.custom_password.clone().ok_or_else(|| {
|
||||
CoreError::conflict(
|
||||
"the link's password could not be read; set a new one or remove it",
|
||||
)
|
||||
})?)
|
||||
}
|
||||
LinkChange::Keep | LinkChange::Remove => None,
|
||||
LinkChange::Set(password) if password.is_empty() => {
|
||||
return Err(CoreError::invalid("the new password is empty"));
|
||||
}
|
||||
LinkChange::Set(password) => Some(password),
|
||||
};
|
||||
let expires = match expires {
|
||||
LinkChange::Keep => link.expiration_time,
|
||||
LinkChange::Remove => None,
|
||||
LinkChange::Set(at) => Some(at),
|
||||
};
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(
|
||||
self.client
|
||||
.update_public_link(uid, role, password.as_deref(), expires),
|
||||
)
|
||||
.map_err(|e| CoreError::from_api(&e, "update public link"))?;
|
||||
Ok(public_link_info(link))
|
||||
}
|
||||
|
||||
/// Remove the public link `id` from the node at `rel`.
|
||||
pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> CoreResult<()> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
|
||||
@@ -130,6 +130,15 @@ impl Daemon {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the mount believes Proton is reachable, as `pdfs status` shows
|
||||
/// it.
|
||||
pub(crate) fn online(&self) -> io::Result<bool> {
|
||||
match self.request(&Request::Status)? {
|
||||
Response::Status { online, .. } => Ok(online),
|
||||
other => Err(io::Error::other(format!("unexpected answer {other:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one control request, as the `pdfs` CLI does, and fail on a
|
||||
/// refusal.
|
||||
pub(crate) fn request(&self, request: &Request) -> io::Result<Response> {
|
||||
@@ -648,7 +657,19 @@ mod tests {
|
||||
.pending()
|
||||
.is_ok_and(|items| items.iter().any(|item| item.attempts > 0))
|
||||
}));
|
||||
// The drain records the failure just before it takes the mount
|
||||
// offline. Left online, only the event poll would, much later.
|
||||
assert!(wait_until(Duration::from_secs(2), || daemon
|
||||
.online()
|
||||
.is_ok_and(|online| !online)));
|
||||
daemon.client.set_online(true);
|
||||
// The probe that sees the link return runs 2 s after the drop, then
|
||||
// 6 s, and a loaded run can reach this line after the first one. So
|
||||
// the 3 s below count from the mount being back, not from the link:
|
||||
// an op left on its backoff waits 4 s or more after its failure.
|
||||
assert!(wait_until(Duration::from_secs(30), || daemon
|
||||
.online()
|
||||
.unwrap_or(false)));
|
||||
assert!(
|
||||
wait_until(Duration::from_secs(3), || {
|
||||
daemon.pending().is_ok_and(|items| items.is_empty())
|
||||
@@ -2214,6 +2235,75 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||
fn a_pdfs_copy_copies_on_drive_and_waits_for_unsynced_changes() {
|
||||
let drive = FakeDrive::new();
|
||||
let dir = scratch("pdfs-copy");
|
||||
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||
|
||||
let mount = &daemon.mountpoint;
|
||||
std::fs::create_dir_all(mount.join("src/inner")).unwrap();
|
||||
std::fs::create_dir_all(mount.join("dest")).unwrap();
|
||||
std::fs::write(mount.join("src/a.txt"), b"alpha").unwrap();
|
||||
std::fs::write(mount.join("src/inner/b.txt"), b"beta").unwrap();
|
||||
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
|
||||
assert!(wait_until(Duration::from_secs(30), || {
|
||||
drive.tree().get("src/inner/b.txt").cloned() == file(b"beta")
|
||||
&& daemon.pending().is_ok_and(|ops| ops.is_empty())
|
||||
}));
|
||||
|
||||
let path = |rel: &str| mount.join(rel).to_string_lossy().into_owned();
|
||||
let copy = |from: &str, to: &str| {
|
||||
daemon.request(&Request::Copy {
|
||||
path: path(from),
|
||||
new_parent: path(to),
|
||||
})
|
||||
};
|
||||
let uploads = drive.tree().len();
|
||||
copy("src/a.txt", "dest").unwrap();
|
||||
// A name already taken gets a suffix rather than an error.
|
||||
copy("src/a.txt", "dest").unwrap();
|
||||
copy("src", "dest").unwrap();
|
||||
assert_eq!(std::fs::read(mount.join("dest/a.txt")).unwrap(), b"alpha");
|
||||
assert_eq!(
|
||||
std::fs::read(mount.join("dest/a (1).txt")).unwrap(),
|
||||
b"alpha"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(mount.join("dest/src/inner/b.txt")).unwrap(),
|
||||
b"beta"
|
||||
);
|
||||
let tree = drive.tree();
|
||||
assert_eq!(tree.get("dest/src/a.txt").cloned(), file(b"alpha"));
|
||||
assert_eq!(
|
||||
tree.len(),
|
||||
uploads + 6,
|
||||
"{:?}",
|
||||
tree.keys().collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// Drive would copy what it has, not the bytes still queued.
|
||||
let paused = |paused| Request::SetSyncPaused {
|
||||
paused,
|
||||
until: None,
|
||||
};
|
||||
daemon.request(&paused(true)).unwrap();
|
||||
std::fs::write(mount.join("src/a.txt"), b"alpha, edited").unwrap();
|
||||
match copy("src/a.txt", "dest") {
|
||||
Err(e) => assert!(e.to_string().contains("not reached Proton Drive"), "{e}"),
|
||||
Ok(answer) => panic!("the copy went ahead: {answer:?}"),
|
||||
}
|
||||
match copy("src", "dest") {
|
||||
Err(e) => assert!(e.to_string().contains("not reached Proton Drive"), "{e}"),
|
||||
Ok(answer) => panic!("the folder copy went ahead: {answer:?}"),
|
||||
}
|
||||
daemon.request(&paused(false)).unwrap();
|
||||
|
||||
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||
fn a_pdfs_rename_after_a_queued_rename_is_not_undone() {
|
||||
@@ -2364,6 +2454,152 @@ mod tests {
|
||||
eprintln!("wifi: syscalls {wifi_syscalls:?}, drain {wifi_drain:?}");
|
||||
}
|
||||
|
||||
/// `copy_file_range` inside the mount is answered from the bytes the daemon
|
||||
/// holds, and left to the kernel for bytes it would have to download. Every
|
||||
/// source kind has to come out byte for byte: cached, queued, open with
|
||||
/// unsaved writes, and on Drive only.
|
||||
#[test]
|
||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||
fn a_copy_inside_the_mount_has_the_source_bytes() {
|
||||
use std::os::fd::AsRawFd as _;
|
||||
use std::os::unix::fs::FileExt as _;
|
||||
|
||||
fn copy_range(from: &std::fs::File, at: u64, to: &std::fs::File, to_at: u64, len: usize) {
|
||||
let (mut at, mut to_at) = (at as i64, to_at as i64);
|
||||
let mut left = len;
|
||||
while left > 0 {
|
||||
// SAFETY: both descriptors are open; the offsets are ours.
|
||||
let n = unsafe {
|
||||
libc::copy_file_range(
|
||||
from.as_raw_fd(),
|
||||
&mut at,
|
||||
to.as_raw_fd(),
|
||||
&mut to_at,
|
||||
left,
|
||||
0,
|
||||
)
|
||||
};
|
||||
assert!(n > 0, "copy_file_range: {}", io::Error::last_os_error());
|
||||
left -= n as usize;
|
||||
}
|
||||
}
|
||||
|
||||
let pattern = |len: usize, seed: u8| -> Vec<u8> {
|
||||
(0..len).map(|i| (i % 253) as u8 ^ seed).collect()
|
||||
};
|
||||
let drive = FakeDrive::new();
|
||||
// On Drive before the mount knows it, so nothing of it is cached.
|
||||
let remote = pattern(9 << 20, 1);
|
||||
drive.device().write("remote.bin", &remote);
|
||||
let dir = scratch("copy-range");
|
||||
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||
let mount = &daemon.mountpoint;
|
||||
|
||||
// Downloaded through the kernel's own copy, then served from the cache.
|
||||
std::fs::copy(mount.join("remote.bin"), mount.join("remote-1.bin")).unwrap();
|
||||
assert!(std::fs::read(mount.join("remote-1.bin")).unwrap() == remote);
|
||||
std::fs::copy(mount.join("remote.bin"), mount.join("remote-2.bin")).unwrap();
|
||||
assert!(std::fs::read(mount.join("remote-2.bin")).unwrap() == remote);
|
||||
|
||||
// Queued and not on Drive yet: the staged blob is the content.
|
||||
let paused = |paused| Request::SetSyncPaused {
|
||||
paused,
|
||||
until: None,
|
||||
};
|
||||
daemon.request(&paused(true)).unwrap();
|
||||
let queued = pattern(20 << 20, 2);
|
||||
std::fs::write(mount.join("queued.bin"), &queued).unwrap();
|
||||
std::fs::copy(mount.join("queued.bin"), mount.join("queued-copy.bin")).unwrap();
|
||||
assert!(std::fs::read(mount.join("queued-copy.bin")).unwrap() == queued);
|
||||
|
||||
// Open with writes not saved yet: the copy sees them, as a read would.
|
||||
let open = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(mount.join("queued.bin"))
|
||||
.unwrap();
|
||||
open.write_all_at(b"UNSAVED", 100).unwrap();
|
||||
let target = std::fs::File::create(mount.join("unsaved-copy.bin")).unwrap();
|
||||
copy_range(&open, 96, &target, 0, 16);
|
||||
drop(target);
|
||||
drop(open);
|
||||
let mut expected = queued[96..112].to_vec();
|
||||
expected[4..11].copy_from_slice(b"UNSAVED");
|
||||
assert_eq!(
|
||||
std::fs::read(mount.join("unsaved-copy.bin")).unwrap(),
|
||||
expected
|
||||
);
|
||||
|
||||
// A range into the middle of a file keeps the bytes around it.
|
||||
std::fs::write(mount.join("middle.bin"), b"0123456789").unwrap();
|
||||
let source = std::fs::File::open(mount.join("remote.bin")).unwrap();
|
||||
let target = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(mount.join("middle.bin"))
|
||||
.unwrap();
|
||||
copy_range(&source, 5, &target, 3, 4);
|
||||
drop(target);
|
||||
let mut expected = b"0123456789".to_vec();
|
||||
expected[3..7].copy_from_slice(&remote[5..9]);
|
||||
assert_eq!(std::fs::read(mount.join("middle.bin")).unwrap(), expected);
|
||||
|
||||
daemon.request(&paused(false)).unwrap();
|
||||
let mut edited = queued.clone();
|
||||
edited[100..107].copy_from_slice(b"UNSAVED");
|
||||
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
|
||||
assert!(wait_until(Duration::from_secs(120), || {
|
||||
let tree = drive.tree();
|
||||
daemon.pending().is_ok_and(|ops| ops.is_empty())
|
||||
&& tree.get("queued.bin").cloned() == file(&edited)
|
||||
&& tree.get("queued-copy.bin").cloned() == file(&queued)
|
||||
&& tree.get("remote-2.bin").cloned() == file(&remote)
|
||||
&& tree.get("middle.bin").cloned() == file(&expected)
|
||||
}));
|
||||
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// How long `cp` inside the mount takes for a file the daemon already
|
||||
/// holds, cold and from the page cache. It decides whether the daemon
|
||||
/// should answer `copy_file_range` itself rather than leave the kernel to
|
||||
/// copy through read and write. Runs when `PDFS_SIM_MEASURE` is set.
|
||||
#[test]
|
||||
#[ignore = "mounts FUSE: run with `PDFS_SIM_MEASURE=1 cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||
fn a_copy_inside_the_mount_measures_its_speed() {
|
||||
if std::env::var_os("PDFS_SIM_MEASURE").is_none() {
|
||||
eprintln!("skipped: set PDFS_SIM_MEASURE to measure the copy");
|
||||
return;
|
||||
}
|
||||
const SIZE: usize = 256 << 20;
|
||||
let drive = FakeDrive::new();
|
||||
let dir = scratch("copy-speed");
|
||||
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||
let mount = &daemon.mountpoint;
|
||||
let bytes: Vec<u8> = (0..SIZE).map(|i| (i % 251) as u8).collect();
|
||||
std::fs::write(mount.join("big.bin"), &bytes).unwrap();
|
||||
assert!(wait_until(Duration::from_secs(120), || {
|
||||
daemon.pending().is_ok_and(|ops| ops.is_empty())
|
||||
}));
|
||||
for (round, name) in ["copy-1.bin", "copy-2.bin", "copy-3.bin"]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let start = Instant::now();
|
||||
let status = std::process::Command::new("cp")
|
||||
.arg(mount.join("big.bin"))
|
||||
.arg(mount.join(name))
|
||||
.status()
|
||||
.unwrap();
|
||||
let took = start.elapsed();
|
||||
assert!(status.success());
|
||||
let mib_per_s = (SIZE >> 20) as f64 / took.as_secs_f64();
|
||||
eprintln!("cp round {round}: {took:?}, {mib_per_s:.0} MiB/s");
|
||||
assert!(std::fs::read(mount.join(name)).unwrap() == bytes);
|
||||
}
|
||||
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||
fn half_of_a_wide_folder_unlinked_at_once_leaves_the_other_half() {
|
||||
@@ -2658,4 +2894,177 @@ mod tests {
|
||||
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The synced folder `id` as the daemon lists it.
|
||||
fn listed_folder(daemon: &Daemon, id: i64) -> Option<control::SyncFolderInfo> {
|
||||
match daemon.request(&Request::ListSyncFolders) {
|
||||
Ok(Response::SyncFolders { items }) => items.into_iter().find(|f| f.id == id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A mirror folder holding `a.txt` and `sub/b.txt`, synced to Drive, whose
|
||||
/// local copy is then deleted in full while the folder is paused, so no
|
||||
/// pass sees half of it. Waits for the pass that refuses to run (#46).
|
||||
fn folder_whose_local_copy_is_gone(daemon: &Daemon, drive: &FakeDrive, local: &Path) -> i64 {
|
||||
let id = sync_folder(daemon, local, "mirror");
|
||||
std::fs::write(local.join("a.txt"), b"alpha").unwrap();
|
||||
std::fs::create_dir(local.join("sub")).unwrap();
|
||||
std::fs::write(local.join("sub/b.txt"), b"beta").unwrap();
|
||||
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
|
||||
let on_drive = |name: &str, content: &[u8]| {
|
||||
drive.tree().iter().any(|(path, entry)| {
|
||||
path.ends_with(name) && *entry == Entry::File(Arc::new(content.to_vec()))
|
||||
})
|
||||
};
|
||||
assert!(
|
||||
wait_until(Duration::from_secs(30), || on_drive("a.txt", b"alpha")
|
||||
&& on_drive("sub/b.txt", b"beta")
|
||||
&& listed_folder(daemon, id).is_some_and(|f| f.state == "idle")),
|
||||
"the folder synced: {:?} {:?}",
|
||||
listed_folder(daemon, id),
|
||||
drive.tree().keys().collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
daemon
|
||||
.request(&Request::SetSyncFolderPaused { id, paused: true })
|
||||
.unwrap();
|
||||
std::fs::remove_file(local.join("a.txt")).unwrap();
|
||||
std::fs::remove_dir_all(local.join("sub")).unwrap();
|
||||
daemon
|
||||
.request(&Request::SetSyncFolderPaused { id, paused: false })
|
||||
.unwrap();
|
||||
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
|
||||
assert!(
|
||||
wait_until(Duration::from_secs(30), || listed_folder(daemon, id)
|
||||
.is_some_and(|f| f.state == "error")),
|
||||
"the pass refused to trash everything: {:?}",
|
||||
listed_folder(daemon, id)
|
||||
);
|
||||
assert!(
|
||||
on_drive("a.txt", b"alpha") && on_drive("sub/b.txt", b"beta"),
|
||||
"nothing was trashed on Drive: {:?}",
|
||||
drive.tree().keys().collect::<Vec<_>>()
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||
fn a_folder_whose_local_copy_is_gone_downloads_again_after_a_reset() {
|
||||
let drive = FakeDrive::new();
|
||||
let dir = scratch("reset-gone-local-copy");
|
||||
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||
let local = dir.join("docs");
|
||||
let id = folder_whose_local_copy_is_gone(&daemon, &drive, &local);
|
||||
|
||||
daemon.request(&Request::ResetSyncFolder { id }).unwrap();
|
||||
let has = |rel: &str, content: &[u8]| {
|
||||
std::fs::read(local.join(rel)).is_ok_and(|bytes| bytes == content)
|
||||
};
|
||||
assert!(
|
||||
wait_until(Duration::from_secs(30), || has("a.txt", b"alpha")
|
||||
&& has("sub/b.txt", b"beta")
|
||||
&& listed_folder(&daemon, id).is_some_and(|f| f.state == "idle")),
|
||||
"the reset downloaded the folder again: {:?}",
|
||||
listed_folder(&daemon, id)
|
||||
);
|
||||
|
||||
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||
fn a_folder_whose_local_copy_is_gone_still_goes_on_demand() {
|
||||
let drive = FakeDrive::new();
|
||||
let dir = scratch("ondemand-gone-local-copy");
|
||||
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||
let local = dir.join("docs");
|
||||
let id = folder_whose_local_copy_is_gone(&daemon, &drive, &local);
|
||||
|
||||
daemon
|
||||
.request(&Request::SetSyncFolderMode {
|
||||
id,
|
||||
mode: "ondemand".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
wait_until(Duration::from_secs(30), || listed_folder(&daemon, id)
|
||||
.is_some_and(|f| f.mode == "ondemand" && f.pending_mode.is_none())
|
||||
&& is_mounted(&local)),
|
||||
"the queued switch landed: {:?}",
|
||||
listed_folder(&daemon, id)
|
||||
);
|
||||
assert_eq!(std::fs::read(local.join("a.txt")).unwrap(), b"alpha");
|
||||
assert_eq!(std::fs::read(local.join("sub/b.txt")).unwrap(), b"beta");
|
||||
|
||||
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Emptied before any pass saw it, the folder is still `idle`, and the
|
||||
/// switch was refused for good: every path read as "deleted here but not on
|
||||
/// Proton Drive yet" (B255, found by the account run).
|
||||
#[test]
|
||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||
fn a_folder_emptied_before_any_pass_still_goes_on_demand() {
|
||||
let drive = FakeDrive::new();
|
||||
let dir = scratch("ondemand-emptied-idle");
|
||||
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||
let local = dir.join("docs");
|
||||
let id = sync_folder(&daemon, &local, "mirror");
|
||||
std::fs::create_dir(local.join("sub")).unwrap();
|
||||
std::fs::write(local.join("sub/a.txt"), b"alpha").unwrap();
|
||||
std::fs::write(local.join("sub/b.txt"), b"beta").unwrap();
|
||||
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
|
||||
let on_drive = |name: &str, content: &[u8]| {
|
||||
drive.tree().iter().any(|(path, entry)| {
|
||||
path.ends_with(name) && *entry == Entry::File(Arc::new(content.to_vec()))
|
||||
})
|
||||
};
|
||||
assert!(
|
||||
wait_until(Duration::from_secs(30), || on_drive("sub/a.txt", b"alpha")
|
||||
&& on_drive("sub/b.txt", b"beta")
|
||||
&& listed_folder(&daemon, id).is_some_and(|f| f.state == "idle")),
|
||||
"the folder synced: {:?}",
|
||||
listed_folder(&daemon, id)
|
||||
);
|
||||
|
||||
// In one rename, so no pass sees half of it, and asked at once, well
|
||||
// inside the watcher's debounce, so no pass sees it empty either.
|
||||
std::fs::rename(local.join("sub"), dir.join("moved-away")).unwrap();
|
||||
let asked = daemon.request(&Request::SetSyncFolderMode {
|
||||
id,
|
||||
mode: "ondemand".into(),
|
||||
});
|
||||
assert!(
|
||||
matches!(asked, Ok(Response::Ok { .. })),
|
||||
"the switch was refused: {asked:?}"
|
||||
);
|
||||
assert!(
|
||||
listed_folder(&daemon, id)
|
||||
.is_some_and(|f| f.pending_mode.as_deref() == Some("ondemand")),
|
||||
"the switch waits for a pass: {:?}",
|
||||
listed_folder(&daemon, id)
|
||||
);
|
||||
|
||||
assert!(
|
||||
wait_until(Duration::from_secs(30), || listed_folder(&daemon, id)
|
||||
.is_some_and(|f| f.mode == "ondemand" && f.pending_mode.is_none())
|
||||
&& is_mounted(&local)),
|
||||
"the queued switch landed: {:?}",
|
||||
listed_folder(&daemon, id)
|
||||
);
|
||||
assert_eq!(std::fs::read(local.join("sub/a.txt")).unwrap(), b"alpha");
|
||||
assert_eq!(std::fs::read(local.join("sub/b.txt")).unwrap(), b"beta");
|
||||
assert!(
|
||||
on_drive("sub/a.txt", b"alpha") && on_drive("sub/b.txt", b"beta"),
|
||||
"nothing was trashed on Drive: {:?}",
|
||||
drive.tree().keys().collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -61,8 +61,8 @@ use proton_drive_rs::proton_sdk::ids::{
|
||||
DeviceUid, DriveEventId, LinkId, NodeUid, ShareId, VolumeId,
|
||||
};
|
||||
use proton_drive_rs::{
|
||||
Device as Computer, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeKind, NodeMoveItem,
|
||||
RevisionState, Thumbnail,
|
||||
AvailableNameTarget, Device as Computer, DeviceType, DriveEvent, DriveEventScopeId, Node,
|
||||
NodeKind, NodeMoveItem, RevisionState, Thumbnail,
|
||||
};
|
||||
use sha1::{Digest as _, Sha1};
|
||||
|
||||
@@ -185,6 +185,39 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy `uid` and, for a folder, everything live below it under `parent` as
|
||||
/// `name`, the way Drive's copy endpoint does. Returns the copy's uid.
|
||||
fn copy_subtree(&mut self, uid: &NodeUid, parent: &NodeUid, name: String) -> NodeUid {
|
||||
let source = self
|
||||
.current(uid)
|
||||
.cloned()
|
||||
.expect("the caller checked the source");
|
||||
let copy = self.mint_uid();
|
||||
let now = now_secs();
|
||||
self.put(
|
||||
©,
|
||||
Some(Remote {
|
||||
parent: Some(parent.clone()),
|
||||
name,
|
||||
created: now,
|
||||
..source
|
||||
}),
|
||||
);
|
||||
let children: Vec<(NodeUid, String)> = self
|
||||
.nodes
|
||||
.keys()
|
||||
.filter_map(|child| {
|
||||
let remote = self.current(child)?;
|
||||
(remote.parent.as_ref() == Some(uid) && !remote.trashed)
|
||||
.then(|| (child.clone(), remote.name.clone()))
|
||||
})
|
||||
.collect();
|
||||
for (child, child_name) in children {
|
||||
self.copy_subtree(&child, ©, child_name);
|
||||
}
|
||||
copy
|
||||
}
|
||||
|
||||
fn mint_uid(&mut self) -> NodeUid {
|
||||
self.next_link += 1;
|
||||
uid(&format!("L{}", self.next_link))
|
||||
@@ -1490,6 +1523,61 @@ impl DriveApi for FakeClient {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn copy_node(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
new_parent: &NodeUid,
|
||||
name: Option<&str>,
|
||||
) -> Result<NodeUid> {
|
||||
self.request(true, |server| {
|
||||
if !server.is_folder(new_parent) {
|
||||
return Err(api(ResponseCode::DoesNotExist, "parent folder not found"));
|
||||
}
|
||||
let source = server
|
||||
.current(uid)
|
||||
.filter(|remote| !remote.trashed)
|
||||
.cloned()
|
||||
.ok_or_else(|| api(ResponseCode::DoesNotExist, "node not found"))?;
|
||||
if server.is_below(new_parent, uid) {
|
||||
return Err(api(
|
||||
ResponseCode::InvalidRequirements,
|
||||
"cannot copy a folder into itself",
|
||||
));
|
||||
}
|
||||
let name = name.map_or(source.name.clone(), str::to_owned);
|
||||
if server.live_child_named(new_parent, &name).is_some() {
|
||||
return Err(api(
|
||||
ResponseCode::AlreadyExists,
|
||||
"a file or folder with that name already exists",
|
||||
));
|
||||
}
|
||||
Ok(server.copy_subtree(uid, new_parent, name))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_available_name(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
target: AvailableNameTarget,
|
||||
) -> Result<String> {
|
||||
self.request(false, |server| {
|
||||
let (stem, extension) = match (target, name.rsplit_once('.')) {
|
||||
(AvailableNameTarget::File, Some((stem, ext))) if !stem.is_empty() => {
|
||||
(stem, format!(".{ext}"))
|
||||
}
|
||||
_ => (name, String::new()),
|
||||
};
|
||||
let free = std::iter::once(name.to_owned())
|
||||
.chain((1..).map(|n| format!("{stem} ({n}){extension}")))
|
||||
.find(|candidate| server.live_child_named(parent_uid, candidate).is_none())
|
||||
.expect("an unbounded sequence has a free name");
|
||||
Ok(free)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn move_nodes_streaming<'a>(
|
||||
&'a self,
|
||||
items: Vec<NodeMoveItem>,
|
||||
|
||||
@@ -266,6 +266,11 @@ pub(crate) struct State {
|
||||
pub(crate) next_ino: u64,
|
||||
/// Shared write state keyed by inode. Concurrent writers share the scratch file.
|
||||
pub(crate) active_writes: HashMap<u64, WriteHandle>,
|
||||
/// Inodes whose last write handle `release` has taken out of
|
||||
/// [`State::active_writes`] and is still queueing. `close(2)` returns
|
||||
/// before that, and in between the edit is in neither the handles nor the
|
||||
/// queue, so a check for changes not on Drive yet looks here too.
|
||||
pub(crate) releasing: HashSet<u64>,
|
||||
/// Maps file handle id (fh) to inode (ino). Read-only opens use fh 0 and
|
||||
/// have no entry here.
|
||||
pub(crate) handles: HashMap<u64, u64>,
|
||||
@@ -428,6 +433,7 @@ impl State {
|
||||
roots: HashSet::new(),
|
||||
next_ino: FALLBACK_INO,
|
||||
active_writes: HashMap::new(),
|
||||
releasing: HashSet::new(),
|
||||
handles: HashMap::new(),
|
||||
next_fh: 1,
|
||||
dir_snapshots: HashMap::new(),
|
||||
|
||||
@@ -36,6 +36,11 @@ const TICK: Duration = Duration::from_secs(15);
|
||||
/// would train everyone to ignore the real one.
|
||||
const STALL_AFTER: Duration = Duration::from_secs(120);
|
||||
|
||||
/// How long a sync pass may go without reporting progress before it is called
|
||||
/// out. Much longer than [`STALL_AFTER`]: one large file in a pass reports
|
||||
/// nothing until its whole transfer is done.
|
||||
const SYNC_STALL_AFTER: Duration = Duration::from_secs(900);
|
||||
|
||||
/// How often resident size goes into the log.
|
||||
const RSS_EVERY: Duration = Duration::from_secs(300);
|
||||
|
||||
@@ -137,6 +142,26 @@ fn report_stalls(report: &pdfs_core::control::Diagnostics, last_completed: (u64,
|
||||
"the transfer lane has not completed a job since the last check"
|
||||
);
|
||||
}
|
||||
// The engine is one thread for every synced folder, so a dead or stuck one
|
||||
// stops all of them while the worker pool looks idle (B259).
|
||||
if let Some(sync) = &report.sync {
|
||||
if !sync.running {
|
||||
stalled = true;
|
||||
warn!(
|
||||
"the sync engine is not running; synced folders do not sync until the daemon restarts"
|
||||
);
|
||||
} else if let Some(folder) = sync.pass_folder
|
||||
&& sync.pass_idle_secs >= SYNC_STALL_AFTER.as_secs()
|
||||
{
|
||||
stalled = true;
|
||||
warn!(
|
||||
folder,
|
||||
age_secs = sync.pass_age_secs,
|
||||
idle_secs = sync.pass_idle_secs,
|
||||
"a sync pass has not reported progress for a long time"
|
||||
);
|
||||
}
|
||||
}
|
||||
stalled
|
||||
}
|
||||
|
||||
@@ -169,7 +194,7 @@ fn probe(control_socket: &Path) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pdfs_core::control::{Diagnostics, InflightRequest, WorkerState};
|
||||
use pdfs_core::control::{Diagnostics, InflightRequest, SyncEngineState, WorkerState};
|
||||
|
||||
fn busy_worker(age_secs: u64) -> WorkerState {
|
||||
WorkerState {
|
||||
@@ -226,4 +251,45 @@ mod tests {
|
||||
assert!(!report_stalls(&report, (0, 30)));
|
||||
assert!(report_stalls(&report, (0, 40)));
|
||||
}
|
||||
|
||||
fn with_sync(sync: SyncEngineState) -> Diagnostics {
|
||||
Diagnostics {
|
||||
sync: Some(sync),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A sync engine that has ended stops every synced folder while the
|
||||
/// workers look idle, which is what issue #53 looked like (B259).
|
||||
#[test]
|
||||
fn a_stopped_sync_engine_is_a_stall() {
|
||||
assert!(report_stalls(
|
||||
&with_sync(SyncEngineState::default()),
|
||||
(0, 0)
|
||||
));
|
||||
}
|
||||
|
||||
/// A pass that keeps reporting progress is working, however long it runs;
|
||||
/// one that has gone quiet past the threshold is not.
|
||||
#[test]
|
||||
fn only_a_quiet_sync_pass_is_a_stall() {
|
||||
let busy = SyncEngineState {
|
||||
running: true,
|
||||
pass_folder: Some(3),
|
||||
pass_age_secs: SYNC_STALL_AFTER.as_secs() * 4,
|
||||
pass_idle_secs: 5,
|
||||
panics: 0,
|
||||
};
|
||||
assert!(!report_stalls(&with_sync(busy.clone()), (0, 0)));
|
||||
let quiet = SyncEngineState {
|
||||
pass_idle_secs: SYNC_STALL_AFTER.as_secs(),
|
||||
..busy
|
||||
};
|
||||
assert!(report_stalls(&with_sync(quiet), (0, 0)));
|
||||
let idle = SyncEngineState {
|
||||
running: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!report_stalls(&with_sync(idle), (0, 0)));
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,9 @@ mod local_scan;
|
||||
mod model;
|
||||
mod planner;
|
||||
pub(crate) use engine::SYNC_CONCURRENCY;
|
||||
pub(crate) use engine::{SyncMsg, spawn};
|
||||
pub(crate) use engine::{EngineHealth, SyncMsg, spawn};
|
||||
#[cfg(test)]
|
||||
use engine::{Running, burst_order, keep_wanted};
|
||||
#[cfg(test)]
|
||||
use engine::{classify, event_target, settle_with};
|
||||
#[cfg(test)]
|
||||
@@ -67,6 +69,11 @@ use planner::{
|
||||
plan_file, rel_to_path, remote_sig, remote_subtree_changed, revision_moved, tracked_baseline,
|
||||
unchanged_remote_size,
|
||||
};
|
||||
|
||||
/// A pass that took at least this long is logged at info even when it changed
|
||||
/// nothing, so the journal says where a slow engine spends its time.
|
||||
const SLOW_PASS: Duration = Duration::from_secs(30);
|
||||
|
||||
/// What a [`Pending`] op did, so the reconcile can update shared state on the
|
||||
/// engine thread (never inside a task).
|
||||
enum Applied {
|
||||
@@ -154,6 +161,14 @@ impl Core {
|
||||
// The cost is one small file read against a pass that is about to walk the
|
||||
// whole tree.
|
||||
let rules = self.ignore_rules(&local_root);
|
||||
// A first pass is the one a user waits on after adding or restoring a
|
||||
// folder, so it is said at info; the poll's passes stay at debug.
|
||||
if folder.last_sync == 0 {
|
||||
info!(id = folder.id, path = %folder.local_path, "sync: first pass starting");
|
||||
} else {
|
||||
debug!(id = folder.id, "sync: pass starting");
|
||||
}
|
||||
let started = Instant::now();
|
||||
self.progress_begin(folder.id);
|
||||
let result = if push_only {
|
||||
self.push_pass(folder.id, &local_root, &remote_root, &rules)
|
||||
@@ -195,6 +210,18 @@ impl Core {
|
||||
"idle"
|
||||
};
|
||||
let _ = self.db.sync_folder_set_state(folder.id, state, now_secs());
|
||||
let elapsed = started.elapsed();
|
||||
if folder.last_sync == 0 || !outcome.is_empty() || elapsed >= SLOW_PASS {
|
||||
info!(
|
||||
id = folder.id,
|
||||
state,
|
||||
elapsed_ms = elapsed.as_millis() as u64,
|
||||
summary = %outcome.summary(),
|
||||
"sync: pass finished"
|
||||
);
|
||||
} else {
|
||||
debug!(id = folder.id, state, "sync: pass finished");
|
||||
}
|
||||
self.report_unscanned(folder.id, name, &outcome.unscanned);
|
||||
// Summarise a pass that actually moved something. A poll over an
|
||||
// unchanged folder does nothing and says nothing — otherwise the
|
||||
@@ -369,7 +396,20 @@ impl Core {
|
||||
// guard on every pass — wedging the folder's sync permanently — the
|
||||
// moment a rule covers the whole tree.
|
||||
let tracked = tracked_baseline(&baseline, rules, &unscanned);
|
||||
guard_local_wipe(&tracked, &local)?;
|
||||
// A local copy that is gone in its entirety is not trashed on Drive,
|
||||
// for the reason `guard_local_wipe` gives. A full pass stops there, but
|
||||
// this one exists for a switch to on-demand, which needs nothing local
|
||||
// and leaves Drive as it is: refusing the pass would only keep the
|
||||
// folder out of `idle`, wedging the switch for good (#46). So the pass
|
||||
// forgets those paths instead of trashing them, and the mount then
|
||||
// shows everything Drive still has.
|
||||
let local_gone = match guard_local_wipe(&tracked, &local) {
|
||||
Ok(()) => false,
|
||||
Err(reason) => {
|
||||
warn!(id = folder_id, %reason, "sync: going on-demand without trashing anything on Drive");
|
||||
true
|
||||
}
|
||||
};
|
||||
|
||||
// The remote folder uids come from the baseline instead of a walk: every
|
||||
// directory this device has synced recorded its uid there when it was
|
||||
@@ -579,8 +619,27 @@ impl Core {
|
||||
// showing the folder's remote contents — otherwise everything they deleted
|
||||
// comes back the moment the switch lands. Shallowest first, skipping anything
|
||||
// under a folder already trashed (its children went with it).
|
||||
let deletions = local_deletions(&tracked, &local);
|
||||
if local_gone {
|
||||
// Forgotten instead of trashed. A row left behind would read to
|
||||
// the switch as a deletion not on Drive yet and withdraw it, and
|
||||
// to a later full pass as a reason to stop again. Forgotten, a full
|
||||
// pass downloads the paths again.
|
||||
let mut forgotten: HashSet<&str> = HashSet::new();
|
||||
for rel in &deletions {
|
||||
let mut parent = parent_rel(rel);
|
||||
while !parent.is_empty() && !forgotten.contains(parent) {
|
||||
parent = parent_rel(parent);
|
||||
}
|
||||
if parent.is_empty() {
|
||||
let _ = self.db.sync_entries_remove_subtree(folder_id, rel);
|
||||
forgotten.insert(rel);
|
||||
}
|
||||
}
|
||||
return Ok(outcome);
|
||||
}
|
||||
let mut trashed: Vec<String> = Vec::new();
|
||||
for rel in &local_deletions(&tracked, &local) {
|
||||
for rel in &deletions {
|
||||
if trashed
|
||||
.iter()
|
||||
.any(|dir| rel.starts_with(&format!("{dir}/")))
|
||||
@@ -2221,6 +2280,27 @@ pub(crate) fn mirror_tree_unsynced(
|
||||
.map(|r| format!("{r} was deleted here but not on Proton Drive yet"))
|
||||
}
|
||||
|
||||
/// Whether a mirror folder's local copy is gone in full: the folder is still
|
||||
/// there, but not one tracked path of its baseline is.
|
||||
///
|
||||
/// [`mirror_tree_unsynced`] reads that as deletions not carried up yet, which
|
||||
/// would refuse the switch to on-demand for good. It is the wipe guard's case
|
||||
/// instead, which the push pass a queued switch runs settles without trashing
|
||||
/// anything on Drive (#46), so the switch waits for that pass.
|
||||
pub(crate) fn mirror_tree_gone(
|
||||
root: &Path,
|
||||
baseline: &HashMap<String, StoredSyncEntry>,
|
||||
rules: &IgnoreRules,
|
||||
) -> bool {
|
||||
let tracked = filter_baseline(baseline, rules);
|
||||
root.is_dir()
|
||||
&& !tracked.is_empty()
|
||||
&& tracked.keys().all(|rel| {
|
||||
std::fs::symlink_metadata(root.join(rel_to_path(rel)))
|
||||
.is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound)
|
||||
})
|
||||
}
|
||||
|
||||
/// The walk behind both checks. `rules`, when given, only names an ignored
|
||||
/// path as such in the reason; it is still something only this disk has.
|
||||
fn unsynced_local(
|
||||
@@ -2385,6 +2465,72 @@ fn system_mtime_ns(meta: &std::fs::Metadata) -> Option<i64> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A panic in a pass ends that pass and is counted; the engine is not
|
||||
/// left believing the pass still runs (`docs/BUGS.md` B259).
|
||||
#[test]
|
||||
fn a_panicking_pass_is_contained_and_counted() {
|
||||
let health = EngineHealth::default();
|
||||
assert!(!health.contain(7, || panic!("a pass that panics")));
|
||||
let report = health.report();
|
||||
assert_eq!(report.panics, 1);
|
||||
assert_eq!(report.pass_folder, None);
|
||||
assert!(health.contain(7, || {}));
|
||||
assert_eq!(health.report().panics, 1);
|
||||
}
|
||||
|
||||
/// Folders named by id run before the rest of a `ReconcileAll` in the
|
||||
/// same burst, so a folder just added does not wait behind a pass over
|
||||
/// every other folder (`docs/BUGS.md` B260).
|
||||
#[test]
|
||||
fn named_folders_run_before_the_rest_of_a_burst() {
|
||||
let ids: HashSet<i64> = [9, 4].into();
|
||||
assert_eq!(burst_order(&ids, &[1, 4, 6, 9]), [4, 9, 1, 6]);
|
||||
assert_eq!(burst_order(&HashSet::new(), &[1, 4]), [1, 4]);
|
||||
// A folder the list no longer has still gets the pass it asked for.
|
||||
assert_eq!(burst_order(&[12].into(), &[1]), [12, 1]);
|
||||
}
|
||||
|
||||
/// A folder removed and added again keeps its watch under the new id;
|
||||
/// a folder that is gone loses its watch (`docs/BUGS.md` B272).
|
||||
#[test]
|
||||
fn a_re_added_folder_keeps_its_watch_under_the_new_id() {
|
||||
let mut have = vec![(PathBuf::from("/a"), 1), (PathBuf::from("/b"), 2)];
|
||||
let dropped = keep_wanted(&mut have, &[(PathBuf::from("/a"), 7)]);
|
||||
assert_eq!(have, [(PathBuf::from("/a"), 7)]);
|
||||
assert_eq!(dropped, [PathBuf::from("/b")]);
|
||||
}
|
||||
|
||||
/// While a pass runs, the report names its folder, which is what tells a
|
||||
/// long pass apart from an idle engine.
|
||||
#[test]
|
||||
fn the_running_pass_is_reported() {
|
||||
let health = EngineHealth::default();
|
||||
let mut seen = None;
|
||||
health.contain(7, || seen = health.report().pass_folder);
|
||||
assert_eq!(seen, Some(7));
|
||||
}
|
||||
|
||||
/// The engine reads as running only while its thread lives, including
|
||||
/// when the thread ends in a panic.
|
||||
#[test]
|
||||
fn the_engine_reads_as_stopped_once_its_thread_ends() {
|
||||
let health = Arc::new(EngineHealth::default());
|
||||
let thread_health = health.clone();
|
||||
let (started_tx, started_rx) = std::sync::mpsc::channel();
|
||||
let (end_tx, end_rx) = std::sync::mpsc::channel::<()>();
|
||||
let thread = std::thread::spawn(move || {
|
||||
let _running = Running::start(&thread_health);
|
||||
started_tx.send(()).unwrap();
|
||||
let _ = end_rx.recv();
|
||||
panic!("the engine thread panics");
|
||||
});
|
||||
started_rx.recv().unwrap();
|
||||
assert!(health.report().running);
|
||||
end_tx.send(()).unwrap();
|
||||
assert!(thread.join().is_err());
|
||||
assert!(!health.report().running);
|
||||
}
|
||||
|
||||
/// A removal of something already gone has produced the state the sync
|
||||
/// engine wanted, so its baseline is dropped rather than retried forever.
|
||||
/// Anything else stays an error and keeps the baseline (`docs/BUGS.md` B42).
|
||||
@@ -3506,6 +3652,26 @@ mod tests {
|
||||
assert!(reason.unwrap().contains("link"));
|
||||
}
|
||||
|
||||
/// Emptied before any pass saw it, the folder waits for the push pass that
|
||||
/// forgets those paths, instead of being refused for good (B255).
|
||||
#[test]
|
||||
fn only_a_local_copy_gone_in_full_counts_as_gone() {
|
||||
let (root, baseline) = synced_subtree("evict-gone");
|
||||
let rules = rules_for(&[]);
|
||||
let whole = mirror_tree_gone(&root, &baseline, &rules);
|
||||
std::fs::remove_dir_all(root.join("dir/sub")).unwrap();
|
||||
let partly = mirror_tree_gone(&root, &baseline, &rules);
|
||||
std::fs::remove_dir_all(root.join("dir")).unwrap();
|
||||
let emptied = mirror_tree_gone(&root, &baseline, &rules);
|
||||
let none_synced = mirror_tree_gone(&root, &HashMap::new(), &rules);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
let no_root = mirror_tree_gone(&root, &baseline, &rules);
|
||||
assert!(!whole && !partly, "a deletion with survivors is a real one");
|
||||
assert!(emptied);
|
||||
assert!(!none_synced, "an empty baseline has nothing to lose");
|
||||
assert!(!no_root, "a missing folder is the pass's error to report");
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
//! Filesystem watcher, polling, and debounce control plane.
|
||||
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
use pdfs_core::control::SyncEngineState;
|
||||
use tracing::error;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// How long the watcher must go quiet before a burst counts as finished.
|
||||
@@ -23,6 +29,9 @@ const POLL_INTERVAL: Duration = Duration::from_secs(120);
|
||||
/// unusable done one-at-a-time; this bounds concurrency like the bulk-upload
|
||||
/// engine does.
|
||||
pub(crate) const SYNC_CONCURRENCY: usize = 8;
|
||||
/// How long after a failed watch the engine tries the watches again. Each try
|
||||
/// walks the folder's whole tree, so not on every burst.
|
||||
const REWATCH_RETRY: Duration = Duration::from_secs(600);
|
||||
|
||||
/// A message to the sync engine's control thread.
|
||||
pub(crate) enum SyncMsg {
|
||||
@@ -53,32 +62,159 @@ pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) -> Option<std::thread::Jo
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the engine is alive and which pass it is on, for
|
||||
/// [`Core::diagnostics`] and the supervisor.
|
||||
///
|
||||
/// A panic in a pass used to end the engine thread. Every later add, sync-now,
|
||||
/// file event and poll then went into a channel nobody read, the folder stayed
|
||||
/// `syncing`, and nothing said so until a restart (`docs/BUGS.md` B259).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct EngineHealth {
|
||||
running: AtomicBool,
|
||||
pass: Mutex<Option<PassClock>>,
|
||||
panics: AtomicU64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct PassClock {
|
||||
folder: i64,
|
||||
started: Instant,
|
||||
moved: Instant,
|
||||
}
|
||||
|
||||
impl EngineHealth {
|
||||
/// Note that the running pass reported progress.
|
||||
pub(crate) fn moved(&self) {
|
||||
if let Some(pass) = self.pass.lock().as_mut() {
|
||||
pass.moved = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine's state now. `try_lock`, like the rest of the diagnostics:
|
||||
/// a report must not wait on the thing it reports about.
|
||||
pub(crate) fn report(&self) -> SyncEngineState {
|
||||
let pass = self.pass.try_lock().and_then(|pass| *pass);
|
||||
SyncEngineState {
|
||||
running: self.running.load(Ordering::Relaxed),
|
||||
pass_folder: pass.map(|p| p.folder),
|
||||
pass_age_secs: pass.map_or(0, |p| p.started.elapsed().as_secs()),
|
||||
pass_idle_secs: pass.map_or(0, |p| p.moved.elapsed().as_secs()),
|
||||
panics: self.panics.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `pass` over `folder` as the current pass. Returns `false` when it
|
||||
/// panicked: the panic ends that pass, not the engine.
|
||||
pub(super) fn contain(&self, folder: i64, pass: impl FnOnce()) -> bool {
|
||||
let now = Instant::now();
|
||||
*self.pass.lock() = Some(PassClock {
|
||||
folder,
|
||||
started: now,
|
||||
moved: now,
|
||||
});
|
||||
let finished = std::panic::catch_unwind(AssertUnwindSafe(pass)).is_ok();
|
||||
*self.pass.lock() = None;
|
||||
if !finished {
|
||||
self.panics.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
finished
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the engine running for as long as it lives.
|
||||
pub(super) struct Running<'a>(&'a EngineHealth);
|
||||
|
||||
impl<'a> Running<'a> {
|
||||
pub(super) fn start(health: &'a EngineHealth) -> Self {
|
||||
health.running.store(true, Ordering::Relaxed);
|
||||
Self(health)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Running<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.running.store(false, Ordering::Relaxed);
|
||||
if std::thread::panicking() {
|
||||
error!("sync engine stopped by a panic; nothing syncs until the daemon restarts");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One folder's pass, contained: a panic in it sets the folder to `error` and
|
||||
/// leaves the engine running for the next one.
|
||||
fn run_pass(core: &Core, folder: &StoredSyncFolder) {
|
||||
if core
|
||||
.sync_engine
|
||||
.contain(folder.id, || core.reconcile_folder(folder))
|
||||
{
|
||||
return;
|
||||
}
|
||||
core.progress_end(folder.id);
|
||||
let _ = core
|
||||
.db
|
||||
.sync_folder_set_state(folder.id, "error", folder.last_sync);
|
||||
error!(
|
||||
id = folder.id,
|
||||
path = %folder.local_path,
|
||||
"sync: pass panicked; the folder is set to error and tried again on the next poll"
|
||||
);
|
||||
core.log_activity(
|
||||
ActivityKind::Sync,
|
||||
format!("sync failed for {}", base_name(&folder.local_path)),
|
||||
"an internal error stopped the pass; it is tried again on the next poll",
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
/// The pass a `Reconcile(id)` asked for. A folder removed since, or one the
|
||||
/// database cannot read, is said so rather than dropped in silence.
|
||||
fn run_pass_by_id(core: &Core, id: i64) {
|
||||
match core.db.sync_folder_get(id) {
|
||||
Ok(Some(folder)) => run_pass(core, &folder),
|
||||
Ok(None) => debug!(id, "sync: folder removed before its pass; skipping"),
|
||||
Err(e) => warn!(id, error = ?e, "sync: cannot read folder; skipping its pass"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine control loop. Single-threaded so reconcile passes never overlap.
|
||||
fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
||||
let health = core.sync_engine.clone();
|
||||
let _running = Running::start(&health);
|
||||
// Paths the watcher currently covers, mapped to their folder id, so an event
|
||||
// path can be resolved back to the folder it belongs to.
|
||||
let watched: Arc<Mutex<Vec<(PathBuf, i64)>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let tx_events = core.sync_tx.clone();
|
||||
let watched_events = watched.clone();
|
||||
let mut watcher =
|
||||
match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
||||
let Ok(event) = res else { return };
|
||||
// Ignore pure metadata/access noise; content changes are what matter.
|
||||
if matches!(event.kind, notify::EventKind::Access(_)) {
|
||||
return;
|
||||
}
|
||||
let msg = event_target(&watched_events.lock(), &event.paths);
|
||||
let _ = tx_events.send(msg);
|
||||
}) {
|
||||
Ok(w) => w,
|
||||
let handler = move |res: notify::Result<notify::Event>| {
|
||||
let event = match res {
|
||||
Ok(event) => event,
|
||||
// Running out of inotify watches for a new subfolder, say: that
|
||||
// subfolder's changes are then found by the poll only (B262).
|
||||
Err(e) => {
|
||||
warn!(error = %e, "filesystem watcher unavailable; sync is poll-only");
|
||||
// Still run the loop so polling and explicit SyncNow work.
|
||||
poll_only_loop(&core, rx);
|
||||
warn!(error = %e, "sync: filesystem watcher error");
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Ignore pure metadata/access noise; content changes are what matter.
|
||||
if matches!(event.kind, notify::EventKind::Access(_)) {
|
||||
return;
|
||||
}
|
||||
let msg = event_target(&watched_events.lock(), &event.paths);
|
||||
let _ = tx_events.send(msg);
|
||||
};
|
||||
// The local walk skips symlinks, so following them here would only watch
|
||||
// trees outside the folder, and run passes over it for changes made there.
|
||||
let config = notify::Config::default().with_follow_symlinks(false);
|
||||
let mut watcher = match notify::RecommendedWatcher::new(handler, config) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "filesystem watcher unavailable; sync is poll-only");
|
||||
// Still run the loop so polling and explicit SyncNow work.
|
||||
poll_only_loop(&core, rx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Periodic remote poll.
|
||||
{
|
||||
@@ -96,8 +232,9 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
||||
.ok();
|
||||
}
|
||||
|
||||
rewatch(&core, &mut watcher, &watched);
|
||||
reconcile_all(&core);
|
||||
let mut unwatched = rewatch(&core, &mut watcher, &watched);
|
||||
let mut last_rewatch = Instant::now();
|
||||
run_burst(&core, &HashSet::new(), true);
|
||||
|
||||
while let Ok(msg) = rx.recv() {
|
||||
let mut ids: HashSet<i64> = HashSet::new();
|
||||
@@ -110,21 +247,16 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A folder whose watch failed is tried again now and then, not only
|
||||
// when the folder set changes.
|
||||
if unwatched && last_rewatch.elapsed() >= REWATCH_RETRY {
|
||||
do_rewatch = true;
|
||||
}
|
||||
if do_rewatch {
|
||||
rewatch(&core, &mut watcher, &watched);
|
||||
}
|
||||
if all {
|
||||
reconcile_all(&core);
|
||||
} else {
|
||||
for id in ids {
|
||||
if core.sync_paused() {
|
||||
break;
|
||||
}
|
||||
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
||||
core.reconcile_folder(&folder);
|
||||
}
|
||||
}
|
||||
unwatched = rewatch(&core, &mut watcher, &watched);
|
||||
last_rewatch = Instant::now();
|
||||
}
|
||||
run_burst(&core, &ids, all);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +273,7 @@ fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
||||
}
|
||||
});
|
||||
}
|
||||
reconcile_all(core);
|
||||
run_burst(core, &HashSet::new(), true);
|
||||
while let Ok(msg) = rx.recv() {
|
||||
let mut ids: HashSet<i64> = HashSet::new();
|
||||
let mut all = false;
|
||||
@@ -152,21 +284,59 @@ fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
||||
info!("sync engine stopping");
|
||||
return;
|
||||
}
|
||||
if all {
|
||||
reconcile_all(core);
|
||||
} else {
|
||||
for id in ids {
|
||||
if core.sync_paused() {
|
||||
break;
|
||||
}
|
||||
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
||||
core.reconcile_folder(&folder);
|
||||
}
|
||||
run_burst(core, &ids, all);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the passes one burst asked for: the folders named by id, then, when the
|
||||
/// burst also holds a `ReconcileAll`, every other folder.
|
||||
///
|
||||
/// The named folders go first. A poll's `ReconcileAll` in the same burst used
|
||||
/// to take their place, and a folder just added, which has the highest id,
|
||||
/// waited behind a full pass over every other folder (`docs/BUGS.md` B260).
|
||||
///
|
||||
/// Skipped while sync is paused, and cut short when a pause lands between
|
||||
/// passes: the resume sends a fresh [`SyncMsg::ReconcileAll`], which walks
|
||||
/// everything the watcher reported meanwhile.
|
||||
fn run_burst(core: &Core, ids: &HashSet<i64>, all: bool) {
|
||||
if core.sync_paused() {
|
||||
debug!("sync: paused, skipping reconcile");
|
||||
return;
|
||||
}
|
||||
let folders = if all {
|
||||
match core.db.sync_folder_list() {
|
||||
Ok(folders) => folders,
|
||||
Err(e) => {
|
||||
warn!(error = ?e, "sync: cannot list folders");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let listed: Vec<i64> = folders.iter().map(|f| f.id).collect();
|
||||
for id in burst_order(ids, &listed) {
|
||||
if core.sync_paused() {
|
||||
break;
|
||||
}
|
||||
// A folder named by id is read again: it may have been added or
|
||||
// changed after the list was read.
|
||||
match folders.iter().find(|f| f.id == id) {
|
||||
Some(folder) if !ids.contains(&id) => run_pass(core, folder),
|
||||
_ => run_pass_by_id(core, id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The order of a burst's passes: the folders named by id, lowest first, then
|
||||
/// the rest of `listed` in its own order.
|
||||
pub(super) fn burst_order(ids: &HashSet<i64>, listed: &[i64]) -> Vec<i64> {
|
||||
let mut order: Vec<i64> = ids.iter().copied().collect();
|
||||
order.sort_unstable();
|
||||
order.extend(listed.iter().copied().filter(|id| !ids.contains(id)));
|
||||
order
|
||||
}
|
||||
|
||||
/// Absorb the rest of an event burst, returning once the watcher has been quiet
|
||||
/// for `quiet` or `cap` has elapsed since the first event — whichever comes
|
||||
/// first. The caller has already classified the event that opened the burst.
|
||||
@@ -238,26 +408,6 @@ pub(super) fn classify(
|
||||
true
|
||||
}
|
||||
|
||||
/// Reconcile every mirror folder in turn. Skipped while sync is paused: the
|
||||
/// resume sends a fresh [`SyncMsg::ReconcileAll`], which walks everything the
|
||||
/// watcher reported meanwhile.
|
||||
fn reconcile_all(core: &Core) {
|
||||
if core.sync_paused() {
|
||||
debug!("sync: paused, skipping reconcile");
|
||||
return;
|
||||
}
|
||||
let folders = match core.db.sync_folder_list() {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
warn!(error = ?e, "sync: cannot list folders");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for folder in folders {
|
||||
core.reconcile_folder(&folder);
|
||||
}
|
||||
}
|
||||
|
||||
/// The reconcile a watcher event asks for: the one mirror folder all its paths
|
||||
/// lie in, so an edit in one folder does not walk every other one too. An event
|
||||
/// that spans folders, or that no watched folder claims (a watch list a moment
|
||||
@@ -278,7 +428,8 @@ pub(super) fn event_target(watched: &[(PathBuf, i64)], paths: &[PathBuf]) -> Syn
|
||||
}
|
||||
|
||||
/// Bring the filesystem watches in line with the current mirror-folder set:
|
||||
/// watch newly-added folders, drop removed ones.
|
||||
/// watch newly-added folders, drop removed ones. Returns whether a folder is
|
||||
/// left unwatched because its watch failed.
|
||||
///
|
||||
/// The watch calls run on a copy, outside the lock: the watcher's event
|
||||
/// callback takes the same lock, and a backend that hands a watch request to
|
||||
@@ -287,7 +438,7 @@ fn rewatch(
|
||||
core: &Core,
|
||||
watcher: &mut notify::RecommendedWatcher,
|
||||
watched: &Mutex<Vec<(PathBuf, i64)>>,
|
||||
) {
|
||||
) -> bool {
|
||||
let want: Vec<(PathBuf, i64)> = match core.db.sync_folder_list() {
|
||||
Ok(f) => f
|
||||
.into_iter()
|
||||
@@ -296,30 +447,60 @@ fn rewatch(
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
warn!(error = ?e, "sync: cannot list folders to watch");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let mut have = watched.lock().clone();
|
||||
// Drop watches no longer wanted.
|
||||
have.retain(|(path, _)| {
|
||||
if want.iter().any(|(p, _)| p == path) {
|
||||
true
|
||||
} else {
|
||||
let _ = watcher.unwatch(path);
|
||||
false
|
||||
}
|
||||
});
|
||||
for path in keep_wanted(&mut have, &want) {
|
||||
let _ = watcher.unwatch(&path);
|
||||
}
|
||||
// Add newly wanted watches.
|
||||
let mut failed = false;
|
||||
for (path, id) in &want {
|
||||
if have.iter().any(|(p, _)| p == path) {
|
||||
continue;
|
||||
}
|
||||
match watcher.watch(path, RecursiveMode::Recursive) {
|
||||
Ok(()) => have.push((path.clone(), *id)),
|
||||
Err(e) => warn!(path = %path.display(), error = %e, "sync: watch failed"),
|
||||
Err(e) => {
|
||||
// A recursive watch that fails partway keeps the watches it had
|
||||
// added. Their events would match no folder and run a pass over
|
||||
// every one, and they hold watches the next folder needs (B262).
|
||||
let _ = watcher.unwatch(path);
|
||||
failed = true;
|
||||
warn!(
|
||||
path = %path.display(),
|
||||
error = %e,
|
||||
"sync: watch failed; the poll finds this folder's changes until a retry \
|
||||
works. A low fs.inotify.max_user_watches is the usual cause"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
*watched.lock() = have;
|
||||
failed
|
||||
}
|
||||
|
||||
/// Keep the watches in `have` that `want` still asks for, and return the paths
|
||||
/// of the others, to unwatch.
|
||||
///
|
||||
/// A path is matched alone, but takes the id `want` gives it: a folder removed
|
||||
/// and added again in one burst keeps its watch, and its events go to the new
|
||||
/// row rather than to the removed one (`docs/BUGS.md` B272).
|
||||
pub(super) fn keep_wanted(have: &mut Vec<(PathBuf, i64)>, want: &[(PathBuf, i64)]) -> Vec<PathBuf> {
|
||||
let mut dropped = Vec::new();
|
||||
have.retain_mut(|(path, id)| match want.iter().find(|(p, _)| p == path) {
|
||||
Some((_, wanted)) => {
|
||||
*id = *wanted;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
dropped.push(path.clone());
|
||||
false
|
||||
}
|
||||
});
|
||||
dropped
|
||||
}
|
||||
|
||||
// ---- reconcile ------------------------------------------------------------
|
||||
@@ -192,7 +192,8 @@ pub(super) fn guard_local_wipe<B, L>(
|
||||
if !baseline.is_empty() && baseline.keys().all(|rel| !local.contains_key(rel)) {
|
||||
return Err(format!(
|
||||
"every one of the {} synced paths is missing locally; refusing to trash \
|
||||
them on Drive. Check that the folder is mounted and readable.",
|
||||
them on Drive. Check that the folder is mounted and readable; to download \
|
||||
them again, sync the folder from scratch.",
|
||||
baseline.len()
|
||||
));
|
||||
}
|
||||
@@ -213,7 +214,8 @@ pub(super) fn guard_remote_wipe<B, R>(
|
||||
if !baseline.is_empty() && baseline.keys().all(|rel| !remote.contains_key(rel)) {
|
||||
return Err(format!(
|
||||
"every one of the {} synced paths is missing on Proton Drive; refusing to \
|
||||
delete them here. If they were deleted on purpose, remove the folder from sync.",
|
||||
delete them here. If they were deleted on purpose, remove the folder from sync; \
|
||||
to upload them again, sync the folder from scratch.",
|
||||
baseline.len()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ pub(crate) mod pages;
|
||||
pub(crate) mod reload;
|
||||
#[path = "../theme.rs"]
|
||||
pub(crate) mod theme;
|
||||
pub(crate) mod update;
|
||||
pub(crate) mod widgets;
|
||||
|
||||
use compat::*;
|
||||
@@ -33,6 +34,7 @@ use pages::trash::*;
|
||||
use pages::verify::*;
|
||||
use reload::{LoadTicket, Loader, replace_items};
|
||||
use theme::{load_resources, proton_theme_active, set_proton_theme};
|
||||
use update::*;
|
||||
use widgets::compat::*;
|
||||
use widgets::crumb::*;
|
||||
use widgets::details::*;
|
||||
@@ -73,11 +75,11 @@ use pdfs_core::config::{AppDirs, FileSort, FilesView, WindowState};
|
||||
|
||||
use pdfs_core::control::{
|
||||
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
|
||||
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, MappingProgress, PendingOpInfo,
|
||||
PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope, Request, Response,
|
||||
RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
|
||||
SyncFolderInfo, SyncIssue, SyncPhase, SyncProgress, ThumbnailBuildStatus, Topic,
|
||||
TransferDirection, TransferItem, send,
|
||||
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, LinkChange, MappingProgress,
|
||||
PendingOpInfo, PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope,
|
||||
Request, Response, RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry,
|
||||
ShareEntryKind, SharedItem, SyncFolderInfo, SyncIssue, SyncPhase, SyncProgress,
|
||||
ThumbnailBuildStatus, Topic, TransferDirection, TransferItem, send,
|
||||
};
|
||||
|
||||
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
||||
@@ -155,6 +157,7 @@ struct Ui {
|
||||
pub(crate) locations: LocationsState,
|
||||
pub(crate) activity: ActivityState,
|
||||
pub(crate) takeout: TakeoutState,
|
||||
pub(crate) update: UpdateState,
|
||||
}
|
||||
|
||||
impl Ui {
|
||||
@@ -365,7 +368,8 @@ fn build_window(app: &adw::Application) {
|
||||
// Sidebar: the signed-in destinations. Selecting a row swaps the page stack;
|
||||
// `sync_sidebar` pushes the other way when navigation happens elsewhere (e.g.
|
||||
// login lands on Files).
|
||||
let (sidebar_page, sidebar_list) = build_sidebar(&main_widgets.footer);
|
||||
let update = UpdateState::new();
|
||||
let (sidebar_page, sidebar_list) = build_sidebar(&main_widgets.footer, &update.banner);
|
||||
|
||||
// Every page brings its own header bar (see `page_frame`), so the content
|
||||
// side is the bare stack.
|
||||
@@ -432,6 +436,7 @@ fn build_window(app: &adw::Application) {
|
||||
theme_row: main_widgets.theme_row.clone(),
|
||||
online_map_row: main_widgets.online_map_row.clone(),
|
||||
tray_row: main_widgets.tray_row.clone(),
|
||||
update_row: main_widgets.update_row.clone(),
|
||||
language_row: main_widgets.language_row.clone(),
|
||||
settings_suppress: Cell::new(false),
|
||||
budget_source: RefCell::new(None),
|
||||
@@ -654,6 +659,7 @@ fn build_window(app: &adw::Application) {
|
||||
running: Cell::new(false),
|
||||
dry_run: Cell::new(false),
|
||||
},
|
||||
update,
|
||||
});
|
||||
wire_login(&ui);
|
||||
if let Some(e) = keyring_error {
|
||||
@@ -700,6 +706,7 @@ fn build_window(app: &adw::Application) {
|
||||
wire_activity(&ui, &activity_widgets);
|
||||
wire_takeout(&ui, &takeout_widgets);
|
||||
wire_retry(&ui);
|
||||
wire_update(&ui);
|
||||
|
||||
// Lazily load the Files / Photos pages the first time they're shown, so the
|
||||
// network round-trip only happens on demand rather than on every refresh.
|
||||
@@ -871,10 +878,13 @@ const DESTINATIONS: [(&str, &str, &str); 7] = [
|
||||
/// Index of the first row in the sidebar's sync section.
|
||||
const SIDEBAR_SYNC_SECTION: i32 = 6;
|
||||
|
||||
/// The navigation sidebar: the destinations over a footer carrying the sync
|
||||
/// status, the account quota and the account menu. Returns the page (for the
|
||||
/// The navigation sidebar: the destinations under the update banner and over a
|
||||
/// footer carrying the sync status, the account quota and the account menu. Returns the page (for the
|
||||
/// split view) and the list (to drive + reflect the current page).
|
||||
fn build_sidebar(footer: >k4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
|
||||
fn build_sidebar(
|
||||
footer: >k4::Box,
|
||||
update_banner: &adw::Banner,
|
||||
) -> (adw::NavigationPage, gtk4::ListBox) {
|
||||
let list = gtk4::ListBox::new();
|
||||
list.set_selection_mode(gtk4::SelectionMode::Single);
|
||||
list.add_css_class("navigation-sidebar");
|
||||
@@ -927,6 +937,7 @@ fn build_sidebar(footer: >k4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
|
||||
.build();
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
toolbar.add_top_bar(update_banner);
|
||||
toolbar.set_content(Some(&scroll));
|
||||
toolbar.add_bottom_bar(footer);
|
||||
|
||||
|
||||
@@ -1043,6 +1043,7 @@ pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
|
||||
ActivityKind::Sync => "pdfs-sync-symbolic",
|
||||
ActivityKind::Rename => "document-edit-symbolic",
|
||||
ActivityKind::Move => "go-jump-symbolic",
|
||||
ActivityKind::Copy => "edit-copy-symbolic",
|
||||
ActivityKind::CreateFolder => "folder-new-symbolic",
|
||||
ActivityKind::Trash => "user-trash-symbolic",
|
||||
ActivityKind::DeleteForever | ActivityKind::EmptyTrash => "edit-delete-symbolic",
|
||||
|
||||
@@ -179,6 +179,10 @@ pub(crate) fn activity_title(kind: ActivityKind, ok: bool, name: &str) -> String
|
||||
(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 file or folder name.
|
||||
(ActivityKind::Copy, true) => gettext_f("Copied {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::Copy, false) => gettext_f("Couldn't copy {name}", &args),
|
||||
// Translators: {name} is a folder name.
|
||||
(ActivityKind::CreateFolder, true) => gettext_f("Created folder {name}", &args),
|
||||
// Translators: {name} is a folder name.
|
||||
|
||||
@@ -904,6 +904,12 @@ pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||
menu.item(&gettext("Move To…"), move || {
|
||||
prompt_move(&ui_c, vec![entry_c.clone()])
|
||||
});
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
menu.item(&gettext("Make a Copy"), move || make_copy(&ui_c, &entry_c));
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
menu.item(&gettext("Copy To…"), move || {
|
||||
prompt_copy(&ui_c, vec![entry_c.clone()])
|
||||
});
|
||||
if !entry.is_dir {
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
menu.item(&gettext("Versions…"), move || {
|
||||
@@ -938,6 +944,10 @@ pub(crate) fn bulk_context_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMe
|
||||
menu.item(&gettext("Move To…"), move || {
|
||||
prompt_move(&ui_c, batch.clone())
|
||||
});
|
||||
let (ui_c, batch) = (ui.clone(), entries.clone());
|
||||
menu.item(&gettext("Copy To…"), move || {
|
||||
prompt_copy(&ui_c, batch.clone())
|
||||
});
|
||||
menu.labelled_section(&ngettext_f(
|
||||
"{n} selected",
|
||||
"{n} selected",
|
||||
@@ -1940,17 +1950,63 @@ pub(crate) fn prompt_rename(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
/// still made on Proton Drive, so nothing is downloaded or uploaded again; it
|
||||
/// is sent with absolute paths, which is how the daemon tells locations apart.
|
||||
pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
|
||||
prompt_destination(ui, entries, PickerKind::Move);
|
||||
}
|
||||
|
||||
/// Ask where to copy `entries`, then copy them there on Proton Drive. Only
|
||||
/// My files is offered: a copy is made on the server, inside the mount.
|
||||
pub(crate) fn prompt_copy(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
|
||||
prompt_destination(ui, entries, PickerKind::Copy);
|
||||
}
|
||||
|
||||
/// Copy one entry into the folder it is in. The copy gets a free name, such
|
||||
/// as "report (1).pdf".
|
||||
pub(crate) fn make_copy(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let source = entry_rel(ui, entry);
|
||||
let parent = source
|
||||
.rfind('/')
|
||||
.map(|i| source[..i].to_string())
|
||||
.unwrap_or_default();
|
||||
run_bulk_copy(ui, vec![source], parent);
|
||||
}
|
||||
|
||||
/// What the folder picker chooses a destination for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum PickerKind {
|
||||
Move,
|
||||
Copy,
|
||||
}
|
||||
|
||||
fn prompt_destination(ui: &Rc<Ui>, entries: Vec<DirEntry>, kind: PickerKind) {
|
||||
if entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
let sources: Vec<String> = entries.iter().map(|e| entry_rel(ui, e)).collect();
|
||||
let heading = match entries.as_slice() {
|
||||
[one] => gettext_f("Move “{name}”", &[("name", &one.name)]),
|
||||
many => ngettext_f("Move {n} item", "Move {n} items", many.len() as u64, &[]),
|
||||
let (heading, body, confirm) = match (kind, entries.as_slice()) {
|
||||
(PickerKind::Move, [one]) => (
|
||||
gettext_f("Move “{name}”", &[("name", &one.name)]),
|
||||
gettext("Choose the folder to move into."),
|
||||
gettext("Move Here"),
|
||||
),
|
||||
(PickerKind::Move, many) => (
|
||||
ngettext_f("Move {n} item", "Move {n} items", many.len() as u64, &[]),
|
||||
gettext("Choose the folder to move into."),
|
||||
gettext("Move Here"),
|
||||
),
|
||||
(PickerKind::Copy, [one]) => (
|
||||
gettext_f("Copy “{name}”", &[("name", &one.name)]),
|
||||
gettext("Choose the folder to copy into."),
|
||||
gettext("Copy Here"),
|
||||
),
|
||||
(PickerKind::Copy, many) => (
|
||||
ngettext_f("Copy {n} item", "Copy {n} items", many.len() as u64, &[]),
|
||||
gettext("Choose the folder to copy into."),
|
||||
gettext("Copy Here"),
|
||||
),
|
||||
};
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(heading)
|
||||
.body(gettext("Choose the folder to move into."))
|
||||
.body(body)
|
||||
.build();
|
||||
|
||||
let up = gtk4::Button::builder()
|
||||
@@ -1981,7 +2037,7 @@ pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
|
||||
body.append(&scroll);
|
||||
dialog.set_extra_child(Some(&body));
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("move", &gettext("Move Here"));
|
||||
dialog.add_response("move", &confirm);
|
||||
dialog.set_response_appearance("move", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("move"));
|
||||
dialog.set_close_response("cancel");
|
||||
@@ -1994,6 +2050,7 @@ pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
|
||||
list,
|
||||
up: up.clone(),
|
||||
sources: sources.clone(),
|
||||
kind,
|
||||
folder: RefCell::new(MoveTarget::Locations),
|
||||
generation: Cell::new(0),
|
||||
});
|
||||
@@ -2013,6 +2070,10 @@ pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
|
||||
}
|
||||
match picker_done.folder.borrow().clone() {
|
||||
MoveTarget::Locations => {}
|
||||
MoveTarget::Drive(folder) if kind == PickerKind::Copy => {
|
||||
run_bulk_copy(&ui, sources.clone(), folder)
|
||||
}
|
||||
MoveTarget::Synced { .. } if kind == PickerKind::Copy => {}
|
||||
MoveTarget::Drive(folder) => run_bulk_move(&ui, sources.clone(), folder),
|
||||
MoveTarget::Synced { path, .. } => {
|
||||
let absolute = sources
|
||||
@@ -2088,8 +2149,9 @@ struct MovePicker {
|
||||
location: gtk4::Label,
|
||||
list: gtk4::ListBox,
|
||||
up: gtk4::Button,
|
||||
/// What is being moved, mountpoint-relative.
|
||||
/// What is being moved or copied, mountpoint-relative.
|
||||
sources: Vec<String>,
|
||||
kind: PickerKind,
|
||||
/// The folder on show, and the destination if the user confirms now.
|
||||
folder: RefCell<MoveTarget>,
|
||||
/// Drops a listing that arrives after the user went elsewhere.
|
||||
@@ -2110,11 +2172,13 @@ impl MovePicker {
|
||||
self.location.set_tooltip_text(Some(&tooltip));
|
||||
self.up
|
||||
.set_sensitive(!matches!(folder, MoveTarget::Locations));
|
||||
let allowed = match &folder {
|
||||
MoveTarget::Locations => false,
|
||||
MoveTarget::Drive(rel) => move_target_allowed(&self.sources, rel),
|
||||
let allowed = match (&folder, self.kind) {
|
||||
(MoveTarget::Locations, _) => false,
|
||||
(MoveTarget::Drive(rel), PickerKind::Move) => move_target_allowed(&self.sources, rel),
|
||||
(MoveTarget::Drive(rel), PickerKind::Copy) => copy_target_allowed(&self.sources, rel),
|
||||
// What is moved is in My files, so it cannot be in a synced folder.
|
||||
MoveTarget::Synced { .. } => true,
|
||||
(MoveTarget::Synced { .. }, PickerKind::Move) => true,
|
||||
(MoveTarget::Synced { .. }, PickerKind::Copy) => false,
|
||||
};
|
||||
self.dialog.set_response_enabled("move", allowed);
|
||||
*self.folder.borrow_mut() = folder.clone();
|
||||
@@ -2180,6 +2244,10 @@ impl MovePicker {
|
||||
/// My files and every synced folder, as rows of the top level.
|
||||
async fn locations(&self) -> Option<Vec<PickerRow>> {
|
||||
let mut rows = vec![(gettext("My files"), None, MoveTarget::Drive(String::new()))];
|
||||
// A copy is made on the server, so it can only land in My files.
|
||||
if self.kind == PickerKind::Copy {
|
||||
return Some(rows);
|
||||
}
|
||||
let rx = spawn_request(self.ui.dirs.control_socket(), Request::ListSyncFolders);
|
||||
let Ok(Ok(Response::SyncFolders { mut items })) = rx.recv().await else {
|
||||
// My files alone is still somewhere to move to.
|
||||
@@ -2286,11 +2354,16 @@ pub(crate) fn move_target_allowed(sources: &[String], target: &str) -> bool {
|
||||
.map(|i| path[..i].to_string())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let inside = sources
|
||||
.iter()
|
||||
.any(|src| target == src || target.starts_with(&format!("{src}/")));
|
||||
let all_here = sources.iter().all(|src| parent(src) == target);
|
||||
!inside && !all_here
|
||||
copy_target_allowed(sources, target) && !all_here
|
||||
}
|
||||
|
||||
/// Whether `target` is somewhere `sources` can be copied to: anywhere but into
|
||||
/// one of themselves. The folder they are in is fine; the copy gets a free name.
|
||||
pub(crate) fn copy_target_allowed(sources: &[String], target: &str) -> bool {
|
||||
!sources
|
||||
.iter()
|
||||
.any(|src| target == src || target.starts_with(&format!("{src}/")))
|
||||
}
|
||||
|
||||
/// Move each of `sources` into `target`, one request at a time, then report
|
||||
@@ -2309,38 +2382,14 @@ pub(crate) fn run_bulk_move(ui: &Rc<Ui>, sources: Vec<String>, target: String) {
|
||||
clear_selection(ui);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let mut done = 0usize;
|
||||
let mut failure: Option<(String, ErrorKind)> = None;
|
||||
for path in sources {
|
||||
let rx = spawn_request(
|
||||
socket.clone(),
|
||||
Request::Move {
|
||||
path,
|
||||
new_parent: target.clone(),
|
||||
},
|
||||
);
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => done += 1,
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
failure.get_or_insert((message, kind));
|
||||
}
|
||||
_ => {
|
||||
failure.get_or_insert_with(|| {
|
||||
(
|
||||
gettext("The Proton Drive service didn't respond."),
|
||||
ErrorKind::Internal,
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let (done, failure) = send_each(&socket, sources, |path| Request::Move {
|
||||
path,
|
||||
new_parent: target.clone(),
|
||||
})
|
||||
.await;
|
||||
ui.busy_end();
|
||||
reload_listing(&ui);
|
||||
let place = match target.rsplit('/').next() {
|
||||
// Translators: a folder name in quotes, filled in as {place} in "Moved to {place}".
|
||||
Some(name) if !name.is_empty() => gettext_f("“{name}”", &[("name", name)]),
|
||||
_ => "Proton Drive".to_string(),
|
||||
};
|
||||
let place = quoted_place(&target);
|
||||
match (done, failure) {
|
||||
(0, Some((message, kind))) => {
|
||||
toast_failure(&ui, &gettext("Couldn't move"), &message, kind)
|
||||
@@ -2373,6 +2422,101 @@ pub(crate) fn run_bulk_move(ui: &Rc<Ui>, sources: Vec<String>, target: String) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Copy each of `sources` into `target` on Proton Drive, one request at a
|
||||
/// time, then report once for the lot.
|
||||
pub(crate) fn run_bulk_copy(ui: &Rc<Ui>, sources: Vec<String>, target: String) {
|
||||
if !*ui.mounted.borrow() {
|
||||
toast_error(
|
||||
ui,
|
||||
&gettext("Couldn't copy"),
|
||||
&gettext("Proton Drive isn't connected."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let socket = ui.dirs.control_socket();
|
||||
ui.busy_begin();
|
||||
clear_selection(ui);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let (done, failure) = send_each(&socket, sources, |path| Request::Copy {
|
||||
path,
|
||||
new_parent: target.clone(),
|
||||
})
|
||||
.await;
|
||||
ui.busy_end();
|
||||
reload_listing(&ui);
|
||||
let place = quoted_place(&target);
|
||||
match (done, failure) {
|
||||
(0, Some((message, kind))) => {
|
||||
toast_failure(&ui, &gettext("Couldn't copy"), &message, kind)
|
||||
}
|
||||
(0, None) => {}
|
||||
(n, Some((message, _))) => toast_error(
|
||||
&ui,
|
||||
// Translators: {place} is a quoted folder name or "Proton Drive".
|
||||
&ngettext_f(
|
||||
"Copied {n} item to {place}, but not all",
|
||||
"Copied {n} items to {place}, but not all",
|
||||
n as u64,
|
||||
&[("place", &place)],
|
||||
),
|
||||
&message,
|
||||
),
|
||||
// Translators: {place} is a quoted folder name or "Proton Drive".
|
||||
(1, None) => toast(&ui, &gettext_f("Copied to {place}", &[("place", &place)])),
|
||||
(n, None) => toast(
|
||||
&ui,
|
||||
// Translators: {place} is a quoted folder name or "Proton Drive".
|
||||
&ngettext_f(
|
||||
"Copied {n} item to {place}",
|
||||
"Copied {n} items to {place}",
|
||||
n as u64,
|
||||
&[("place", &place)],
|
||||
),
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Send one request per source, in order, and count how many succeeded. The
|
||||
/// first failure is kept for the report.
|
||||
async fn send_each(
|
||||
socket: &Path,
|
||||
sources: Vec<String>,
|
||||
request: impl Fn(String) -> Request,
|
||||
) -> (usize, Option<(String, ErrorKind)>) {
|
||||
let mut done = 0usize;
|
||||
let mut failure: Option<(String, ErrorKind)> = None;
|
||||
for path in sources {
|
||||
let rx = spawn_request(socket.to_path_buf(), request(path));
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => done += 1,
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
failure.get_or_insert((message, kind));
|
||||
}
|
||||
_ => {
|
||||
failure.get_or_insert_with(|| {
|
||||
(
|
||||
gettext("The Proton Drive service didn't respond."),
|
||||
ErrorKind::Internal,
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
(done, failure)
|
||||
}
|
||||
|
||||
/// The destination folder for a report: its name in quotes, or "Proton Drive"
|
||||
/// for My files.
|
||||
fn quoted_place(target: &str) -> String {
|
||||
match target.rsplit('/').next() {
|
||||
// Translators: a folder name in quotes, filled in as {place} in "Moved to {place}".
|
||||
Some(name) if !name.is_empty() => gettext_f("“{name}”", &[("name", name)]),
|
||||
_ => "Proton Drive".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Move one entry to Trash, through the batch path so it gets the same Undo.
|
||||
pub(crate) fn trash_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
run_bulk_delete(ui, vec![entry.clone()]);
|
||||
@@ -2978,8 +3122,8 @@ fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
MoveTarget, badge_for, hit_location, listing_summary, move_target_allowed,
|
||||
show_thumbnail_build_progress, sort_entries,
|
||||
MoveTarget, badge_for, copy_target_allowed, hit_location, listing_summary,
|
||||
move_target_allowed, show_thumbnail_build_progress, sort_entries,
|
||||
};
|
||||
use pdfs_core::config::{FileSort, FilesView};
|
||||
use pdfs_core::control::{DirEntry, SyncIssue, ThumbnailBuildStatus};
|
||||
@@ -3067,6 +3211,16 @@ mod tests {
|
||||
assert!(move_target_allowed(&mixed, ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_refuses_its_own_subtree_but_not_the_folder_it_is_in() {
|
||||
let sources = ["Docs/Work".to_string()];
|
||||
assert!(!copy_target_allowed(&sources, "Docs/Work"));
|
||||
assert!(!copy_target_allowed(&sources, "Docs/Work/Old"));
|
||||
assert!(copy_target_allowed(&sources, "Docs"));
|
||||
assert!(copy_target_allowed(&sources, ""));
|
||||
assert!(copy_target_allowed(&sources, "Docs/Workshop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listing_summary_matches_dolphin_order_and_wording() {
|
||||
assert_eq!(listing_summary(0, 0, 0), "0 folders, 0 files");
|
||||
|
||||
@@ -629,9 +629,17 @@ pub(crate) fn sync_state_label(state: &str) -> String {
|
||||
/// — so both read "12 of 40" rather than a percentage that could go backwards.
|
||||
pub(crate) fn sync_progress_label(p: &SyncProgress) -> String {
|
||||
match p.phase {
|
||||
// Before the first pass finishes there is no estimate, so the count would
|
||||
// be "checked 12 of 12" — worse than saying nothing.
|
||||
SyncPhase::Scanning if p.total == 0 => gettext("checking for changes…"),
|
||||
SyncPhase::Scanning if p.total == 0 && p.done == 0 => gettext("checking for changes…"),
|
||||
// Before the first pass finishes there is no estimate, so "12 of 12" would
|
||||
// say nothing. The count alone still shows a first scan of a large folder
|
||||
// moving, which can take many minutes before anything downloads.
|
||||
// Translators: {n} counts the folders and files checked so far.
|
||||
SyncPhase::Scanning if p.total == 0 => ngettext_f(
|
||||
"checking for changes — {n} item checked",
|
||||
"checking for changes — {n} items checked",
|
||||
p.done as u64,
|
||||
&[],
|
||||
),
|
||||
// Translators: {done} and {total} count folders and files checked so far.
|
||||
SyncPhase::Scanning => gettext_f(
|
||||
"checking for changes — {done} of {total}",
|
||||
@@ -1296,6 +1304,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn scanning(done: usize, total: usize) -> SyncProgress {
|
||||
SyncProgress {
|
||||
phase: SyncPhase::Scanning,
|
||||
done,
|
||||
total,
|
||||
current: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_first_scan_counts_what_it_checked() {
|
||||
// A first sync of a large folder scans for many minutes before anything
|
||||
// downloads; without a count it reads as stuck (#46).
|
||||
assert_eq!(
|
||||
sync_progress_label(&scanning(0, 0)),
|
||||
"checking for changes…"
|
||||
);
|
||||
assert_eq!(
|
||||
sync_progress_label(&scanning(1, 0)),
|
||||
"checking for changes — 1 item checked"
|
||||
);
|
||||
assert_eq!(
|
||||
sync_progress_label(&scanning(1234, 0)),
|
||||
"checking for changes — 1234 items checked"
|
||||
);
|
||||
assert_eq!(
|
||||
sync_progress_label(&scanning(12, 40)),
|
||||
"checking for changes — 12 of 40"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_restore_picker_names_the_computer_it_restores_from() {
|
||||
assert!(restore_picker_body(Some("laptop")).contains("“laptop”"));
|
||||
|
||||
@@ -1506,8 +1506,13 @@ fn add_device_items(ui: &Rc<Ui>, menu: &mut ActionMenu, spec: &MountSpec, id: i6
|
||||
} else {
|
||||
gettext("Pause Syncing")
|
||||
};
|
||||
let ui_pause = ui.clone();
|
||||
menu.item(&label, move || set_folder_paused(&ui_pause, id, !paused));
|
||||
let ui = ui.clone();
|
||||
menu.item(&label, move || set_folder_paused(&ui, id, !paused));
|
||||
let path = spec.local_path.clone();
|
||||
menu.item(&gettext("Sync From Scratch…"), move || {
|
||||
confirm_reset_sync_folder(&ui, id, &path)
|
||||
});
|
||||
menu.section();
|
||||
}
|
||||
let ui = ui.clone();
|
||||
@@ -1520,6 +1525,51 @@ fn add_device_items(ui: &Rc<Ui>, menu: &mut ActionMenu, spec: &MountSpec, id: i6
|
||||
});
|
||||
}
|
||||
|
||||
/// Ask before syncing a folder from scratch. Nothing is deleted, but files
|
||||
/// that differ come back as conflict copies, so it is not a click to make by
|
||||
/// accident.
|
||||
fn confirm_reset_sync_folder(ui: &Rc<Ui>, id: i64, path: &str) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Sync From Scratch?"))
|
||||
// Translators: {path} is a local folder path.
|
||||
.body(gettext_f("This computer forgets what it synced in {path} before and compares the folder with Proton Drive again. Nothing is deleted: a file on one side only is copied to the other, and a file that differs keeps both versions.", &[("path", path)]))
|
||||
.build();
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("reset", &gettext("Sync From Scratch"));
|
||||
dialog.set_response_appearance("reset", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
let win = ui_window(ui);
|
||||
let ui = ui.clone();
|
||||
dialog.connect_response(None, move |_, response| {
|
||||
if response == "reset" {
|
||||
reset_sync_folder(&ui, id);
|
||||
}
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Forget a folder's sync records, then repaint so its row shows the new pass.
|
||||
fn reset_sync_folder(ui: &Rc<Ui>, id: i64) {
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ResetSyncFolder { id });
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let failed = gettext("Couldn't sync the folder from scratch");
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => toast(&ui, &gettext("Syncing folder…")),
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, &failed, &message, kind)
|
||||
}
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
&failed,
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
refresh_locations(&ui);
|
||||
});
|
||||
}
|
||||
|
||||
/// Ask before making a synced folder online only: the switch frees disk space
|
||||
/// by removing the local copies, which is not something to do by accident.
|
||||
fn confirm_online_only(ui: &Rc<Ui>, id: i64, path: &str) {
|
||||
|
||||
@@ -52,6 +52,9 @@ pub(crate) struct StatusState {
|
||||
pub(crate) online_map_row: adw::SwitchRow,
|
||||
/// "Show tray icon" toggle. Guarded by [`Self::settings_suppress`].
|
||||
pub(crate) tray_row: adw::SwitchRow,
|
||||
/// "Check for updates" toggle, not shown where a package manager brings
|
||||
/// updates. Guarded by [`Self::settings_suppress`].
|
||||
pub(crate) update_row: adw::SwitchRow,
|
||||
/// Language picker: "System Default", then [`i18n::LANGUAGES`]. Guarded by
|
||||
/// [`Self::settings_suppress`].
|
||||
pub(crate) language_row: adw::ComboRow,
|
||||
@@ -164,6 +167,7 @@ pub(crate) struct MainWidgets {
|
||||
pub(crate) theme_row: adw::SwitchRow,
|
||||
pub(crate) online_map_row: adw::SwitchRow,
|
||||
pub(crate) tray_row: adw::SwitchRow,
|
||||
pub(crate) update_row: adw::SwitchRow,
|
||||
pub(crate) language_row: adw::ComboRow,
|
||||
}
|
||||
|
||||
@@ -250,6 +254,17 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
||||
let language_row = build_language_row();
|
||||
appearance_group.add(&language_row);
|
||||
|
||||
let updates_group = adw::PreferencesGroup::builder()
|
||||
.title(gettext("Updates"))
|
||||
.build();
|
||||
let update_row = adw::SwitchRow::builder()
|
||||
.title(gettext("Check for updates"))
|
||||
.subtitle(gettext(
|
||||
"Ask GitHub once a day whether a new version is out. GitHub learns your IP address and which version you use.",
|
||||
))
|
||||
.build();
|
||||
updates_group.add(&update_row);
|
||||
|
||||
let general = adw::PreferencesPage::builder()
|
||||
.title(gettext("General"))
|
||||
.icon_name("preferences-system-symbolic")
|
||||
@@ -258,6 +273,10 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
||||
general.add(&location_group);
|
||||
general.add(&network_group);
|
||||
general.add(&appearance_group);
|
||||
// A package manager brings new versions, so there is nothing to tell.
|
||||
if !updates_managed_here() {
|
||||
general.add(&updates_group);
|
||||
}
|
||||
|
||||
// ---- Preferences: Storage
|
||||
let storage_group = adw::PreferencesGroup::builder()
|
||||
@@ -442,6 +461,7 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
||||
theme_row,
|
||||
online_map_row,
|
||||
tray_row,
|
||||
update_row,
|
||||
language_row,
|
||||
}
|
||||
}
|
||||
@@ -541,6 +561,7 @@ pub(crate) fn wire_settings(
|
||||
.set_active(config.proton_theme.unwrap_or(false));
|
||||
ui.status.online_map_row.set_active(config.online_map);
|
||||
ui.status.tray_row.set_active(!config.tray_hidden);
|
||||
ui.status.update_row.set_active(config.update_check);
|
||||
ui.status
|
||||
.language_row
|
||||
.set_selected(language_index(config.language.as_deref()));
|
||||
@@ -690,6 +711,26 @@ pub(crate) fn wire_settings(
|
||||
}
|
||||
});
|
||||
|
||||
// Update check: saved, and the first check runs right away if one is due.
|
||||
let ui_update = ui.clone();
|
||||
ui.status.update_row.connect_active_notify(move |row| {
|
||||
if ui_update.status.settings_suppress.get() {
|
||||
return;
|
||||
}
|
||||
let mut config = ui_update.dirs.load_config();
|
||||
config.update_check = row.is_active();
|
||||
if let Err(e) = ui_update.dirs.save_config(&config) {
|
||||
toast_error(
|
||||
&ui_update,
|
||||
&gettext("Couldn't save the update setting"),
|
||||
&e.to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
show_latest(&ui_update);
|
||||
start_update_checks(&ui_update);
|
||||
});
|
||||
|
||||
// Tray icon: the tray reads the same flag at start, so hiding it here also
|
||||
// keeps the login autostart from bringing it back.
|
||||
let ui_tray = ui.clone();
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Tells the user when a newer release is out, if they turned on Preferences →
|
||||
//! Updates. The decisions are [`pdfs_core::update`]'s; this asks GitHub and
|
||||
//! shows the banner.
|
||||
|
||||
use crate::*;
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use pdfs_core::update::{
|
||||
RELEASES_LATEST_API, is_newer, latest_version, next_check_in, release_url, updates_managed,
|
||||
};
|
||||
|
||||
/// How long one request to GitHub may take.
|
||||
const FETCH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// How long to wait after a failed check, such as one made offline, before
|
||||
/// trying again. Not saved, so a restart tries again straight away.
|
||||
const RETRY_AFTER: Duration = Duration::from_secs(60 * 60);
|
||||
|
||||
pub(crate) struct UpdateState {
|
||||
/// Over the sidebar, so it shows whichever page is open.
|
||||
pub(crate) banner: adw::Banner,
|
||||
/// The check loop is running. Turning the setting off and on again while it
|
||||
/// sleeps must not start a second one.
|
||||
running: Cell<bool>,
|
||||
/// The release page the banner's button opens.
|
||||
url: RefCell<String>,
|
||||
}
|
||||
|
||||
impl UpdateState {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
banner: adw::Banner::builder()
|
||||
.button_label(gettext("Download"))
|
||||
.build(),
|
||||
running: Cell::new(false),
|
||||
url: RefCell::new(String::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a package manager brings new releases to this install. The app then
|
||||
/// never asks, and Preferences does not offer to.
|
||||
pub(crate) fn updates_managed_here() -> bool {
|
||||
std::env::current_exe().is_ok_and(|exe| updates_managed(&exe))
|
||||
}
|
||||
|
||||
/// Wire the banner, show a release an earlier check found, and start checking
|
||||
/// if the user asked for it.
|
||||
pub(crate) fn wire_update(ui: &Rc<Ui>) {
|
||||
let ui_open = ui.clone();
|
||||
ui.update
|
||||
.banner
|
||||
.connect_button_clicked(move |_| open_uri(&ui_open.update.url.borrow()));
|
||||
show_latest(ui);
|
||||
start_update_checks(ui);
|
||||
}
|
||||
|
||||
/// Start the daily check, unless it is off, managed elsewhere, or running.
|
||||
pub(crate) fn start_update_checks(ui: &Rc<Ui>) {
|
||||
if updates_managed_here()
|
||||
|| !ui.dirs.load_config().update_check
|
||||
|| ui.update.running.replace(true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
// Also kept here, so a config that cannot be saved does not make every
|
||||
// pass ask GitHub again.
|
||||
let mut checked = None;
|
||||
loop {
|
||||
let config = ui.dirs.load_config();
|
||||
if !config.update_check {
|
||||
break;
|
||||
}
|
||||
let wait = next_check_in(config.update_last_checked.max(checked), unix_now());
|
||||
if !wait.is_zero() {
|
||||
glib::timeout_future(wait).await;
|
||||
continue;
|
||||
}
|
||||
match fetch_latest().await {
|
||||
Ok(latest) => {
|
||||
checked = Some(unix_now());
|
||||
let mut config = ui.dirs.load_config();
|
||||
config.update_last_checked = checked;
|
||||
config.update_latest = latest;
|
||||
if let Err(e) = ui.dirs.save_config(&config) {
|
||||
tracing::warn!("update check: cannot save the result: {e}");
|
||||
}
|
||||
show_latest(&ui);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("update check: {e}");
|
||||
glib::timeout_future(RETRY_AFTER).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.update.running.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
/// Reveal the banner if the last check found a release newer than this one,
|
||||
/// and hide it otherwise.
|
||||
pub(crate) fn show_latest(ui: &Ui) {
|
||||
let config = ui.dirs.load_config();
|
||||
let newer = config
|
||||
.update_latest
|
||||
.filter(|latest| config.update_check && is_newer(latest, env!("CARGO_PKG_VERSION")));
|
||||
match newer {
|
||||
Some(version) => {
|
||||
ui.update.banner.set_title(&gettext_f(
|
||||
"Proton Drive {version} is available",
|
||||
&[("version", &version)],
|
||||
));
|
||||
*ui.update.url.borrow_mut() = release_url(&version);
|
||||
ui.update.banner.set_revealed(!updates_managed_here());
|
||||
}
|
||||
None => ui.update.banner.set_revealed(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_now() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs() as i64)
|
||||
}
|
||||
|
||||
/// The latest release's version, asked on a worker thread so the window never
|
||||
/// waits on GitHub. `Ok(None)` when the answer names no `X.Y.Z` release.
|
||||
async fn fetch_latest() -> Result<Option<String>, String> {
|
||||
let (tx, rx) = async_channel::bounded(1);
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send_blocking(fetch_latest_blocking());
|
||||
});
|
||||
rx.recv().await.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
fn fetch_latest_blocking() -> Result<Option<String>, String> {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| format!("no runtime: {e}"))?;
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(concat!(
|
||||
"proton-drive-linux/",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" (",
|
||||
env!("CARGO_PKG_REPOSITORY"),
|
||||
")"
|
||||
))
|
||||
.timeout(FETCH_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|e| format!("no HTTP client: {e}"))?;
|
||||
runtime.block_on(async {
|
||||
let reply = client
|
||||
.get(RELEASES_LATEST_API)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !reply.status().is_success() {
|
||||
return Err(format!("GitHub answered {}", reply.status()));
|
||||
}
|
||||
let body = reply.bytes().await.map_err(|e| e.to_string())?;
|
||||
Ok(latest_version(&body))
|
||||
})
|
||||
}
|
||||
@@ -368,12 +368,19 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
} else {
|
||||
gettext("File")
|
||||
});
|
||||
d.size_row.set_subtitle(&if entry.is_dir {
|
||||
"—".to_string()
|
||||
// A folder's size is Proton Drive's count, which takes a request; it lands
|
||||
// in `load_details_extras`.
|
||||
if entry.is_dir {
|
||||
d.size_row.set_title(&gettext("Size on Proton Drive"));
|
||||
d.size_row.set_subtitle("—");
|
||||
d.size_row.set_tooltip_text(Some(&gettext(
|
||||
"Counted by Proton Drive, including items in the trash.",
|
||||
)));
|
||||
} else {
|
||||
human_bytes(entry.size)
|
||||
});
|
||||
d.size_row.set_visible(!entry.is_dir);
|
||||
d.size_row.set_title(&pgettext("column", "Size"));
|
||||
d.size_row.set_subtitle(&human_bytes(entry.size));
|
||||
d.size_row.set_tooltip_text(None);
|
||||
}
|
||||
d.modified_row
|
||||
.set_subtitle(&dates::short_date(entry.modified));
|
||||
let rel = entry_rel(ui, entry);
|
||||
@@ -426,8 +433,8 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
d.pages.set_visible_child_name("details");
|
||||
}
|
||||
|
||||
/// Fill the Sharing and Versions rows, which need the network. The answers
|
||||
/// land only if the pane still shows the same entry.
|
||||
/// Fill the Sharing and Versions rows, and a folder's size, which need the
|
||||
/// network. The answers land only if the pane still shows the same entry.
|
||||
fn load_details_extras(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let by_uid = entry.path.is_empty() && !entry.uid.is_empty();
|
||||
let rel = entry_rel(ui, entry);
|
||||
@@ -453,6 +460,24 @@ fn load_details_extras(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
});
|
||||
|
||||
if entry.is_dir {
|
||||
let req = if by_uid {
|
||||
Request::FolderSizeByUid {
|
||||
uid: entry.uid.clone(),
|
||||
}
|
||||
} else {
|
||||
Request::FolderSize { path: rel }
|
||||
};
|
||||
let rx = spawn_request(ui.dirs.control_socket(), req);
|
||||
let (ui_c, uid) = (ui.clone(), entry.uid.clone());
|
||||
glib::spawn_future_local(async move {
|
||||
let text = match rx.recv().await {
|
||||
Ok(Ok(Response::FolderSize { size, items })) => folder_size_summary(size, items),
|
||||
_ => gettext("Couldn't load the size"),
|
||||
};
|
||||
if details_showing(&ui_c, &uid) {
|
||||
ui_c.details.details.size_row.set_subtitle(&text);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
let req = if by_uid {
|
||||
@@ -499,6 +524,18 @@ pub(crate) fn sharing_summary(people: usize, link: bool) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// "1.2 GiB · 340 items": what a folder holds, as Proton Drive counts it.
|
||||
pub(crate) fn folder_size_summary(size: u64, items: u64) -> String {
|
||||
// Translators: {size} is a size such as "1.2 GiB"; {n} counts the files
|
||||
// and folders inside a folder.
|
||||
ngettext_f(
|
||||
"{size} · {n} item",
|
||||
"{size} · {n} items",
|
||||
items,
|
||||
&[("size", &human_bytes(size))],
|
||||
)
|
||||
}
|
||||
|
||||
/// "3 versions · latest 2 Sep 2026".
|
||||
pub(crate) fn versions_summary(items: &[RevisionInfo]) -> String {
|
||||
let latest = items.iter().map(|r| r.created).max();
|
||||
@@ -547,6 +584,12 @@ pub(crate) fn selected_entry(ui: &Rc<Ui>) -> Option<DirEntry> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn folder_size_summary_reads_size_and_item_count() {
|
||||
assert_eq!(folder_size_summary(0, 0), "0 bytes · 0 items");
|
||||
assert_eq!(folder_size_summary(2048, 1), "2.0 KiB · 1 item");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharing_summary_names_people_and_link() {
|
||||
assert_eq!(sharing_summary(0, false), "Only you");
|
||||
|
||||
@@ -51,6 +51,48 @@ pub(crate) fn link_expiry_at(idx: u32, now: i64) -> Option<i64> {
|
||||
.map(|days| now + days * 86_400)
|
||||
}
|
||||
|
||||
/// The labels of the Expires dropdown on an existing link. When the link has
|
||||
/// an expiry, the first entry is that expiry, so the dropdown shows what is
|
||||
/// set and choosing it again changes nothing.
|
||||
pub(crate) fn link_expiry_choices(current: Option<i64>, now: i64) -> Vec<String> {
|
||||
let current = current.map(|at| capitalize(&link_expiry_label(at, now)));
|
||||
current
|
||||
.into_iter()
|
||||
.chain(LINK_EXPIRY.iter().map(|(name, _)| gettext(name)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What choosing entry `idx` of [`link_expiry_choices`] does to the expiry.
|
||||
pub(crate) fn link_expiry_change(idx: u32, current: Option<i64>, now: i64) -> LinkChange<i64> {
|
||||
let idx = match (current, idx) {
|
||||
(Some(_), 0) => return LinkChange::Keep,
|
||||
(Some(_), idx) => idx - 1,
|
||||
(None, idx) => idx,
|
||||
};
|
||||
match link_expiry_at(idx, now) {
|
||||
Some(at) => LinkChange::Set(at),
|
||||
None if current.is_some() => LinkChange::Remove,
|
||||
None => LinkChange::Keep,
|
||||
}
|
||||
}
|
||||
|
||||
/// A row with a title and a dropdown of `labels` as its suffix.
|
||||
fn dropdown_row(title: &str, labels: &[String], selected: u32) -> (adw::ActionRow, gtk4::DropDown) {
|
||||
let drop = gtk4::DropDown::builder()
|
||||
.model(&string_list(labels))
|
||||
.selected(selected)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
let row = adw::ActionRow::builder().title(title).build();
|
||||
row.add_suffix(&drop);
|
||||
(row, drop)
|
||||
}
|
||||
|
||||
/// The roles a public link can grant, in dropdown order.
|
||||
fn link_roles() -> [String; 2] {
|
||||
[pgettext("role", "Viewer"), pgettext("role", "Editor")]
|
||||
}
|
||||
|
||||
/// A loose shape check for an email address: one `@` with something before
|
||||
/// it and a dotted domain after it. The server does the real validation; this
|
||||
/// only catches typos before the Invite button is pressed.
|
||||
@@ -237,6 +279,31 @@ impl ShareTarget {
|
||||
}
|
||||
}
|
||||
|
||||
fn update_link(
|
||||
&self,
|
||||
id: String,
|
||||
role: Option<String>,
|
||||
password: LinkChange<String>,
|
||||
expires: LinkChange<i64>,
|
||||
) -> Request {
|
||||
match self {
|
||||
ShareTarget::Path(path) => Request::UpdatePublicLink {
|
||||
path: path.clone(),
|
||||
id,
|
||||
role,
|
||||
password,
|
||||
expires,
|
||||
},
|
||||
ShareTarget::Uid(uid) => Request::UpdatePublicLinkByUid {
|
||||
uid: uid.clone(),
|
||||
id,
|
||||
role,
|
||||
password,
|
||||
expires,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_link(&self, id: String) -> Request {
|
||||
match self {
|
||||
ShareTarget::Path(path) => Request::RemovePublicLink {
|
||||
@@ -585,8 +652,8 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
||||
*state.people_rows.borrow_mut() = rows;
|
||||
}
|
||||
|
||||
/// Rebuild the public-link section: an existing link (copy / remove) or a
|
||||
/// create control.
|
||||
/// Rebuild the public-link section: an existing link (copy / remove, and its
|
||||
/// settings) or a create control.
|
||||
pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLinkInfo>) {
|
||||
for row in state.link_rows.borrow_mut().drain(..) {
|
||||
state.link_group.remove(&row);
|
||||
@@ -670,30 +737,19 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
|
||||
state.link_group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
|
||||
let settings = link_settings_row(state, link);
|
||||
state.link_group.add(&settings);
|
||||
rows.push(settings.upcast());
|
||||
}
|
||||
None => {
|
||||
let role_model = string_list(&[pgettext("role", "Viewer"), pgettext("role", "Editor")]);
|
||||
let role_drop = gtk4::DropDown::builder()
|
||||
.model(&role_model)
|
||||
.selected(0)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
let role_row = adw::ActionRow::builder()
|
||||
.title(gettext("Link role"))
|
||||
.build();
|
||||
role_row.add_suffix(&role_drop);
|
||||
let (role_row, role_drop) = dropdown_row(&gettext("Link role"), &link_roles(), 0);
|
||||
let pw_row = adw::PasswordEntryRow::builder()
|
||||
.title(gettext("Password (optional)"))
|
||||
.build();
|
||||
let expiry_names: Vec<String> =
|
||||
LINK_EXPIRY.iter().map(|(name, _)| gettext(name)).collect();
|
||||
let expiry_drop = gtk4::DropDown::builder()
|
||||
.model(&string_list(&expiry_names))
|
||||
.selected(0)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build();
|
||||
expiry_row.add_suffix(&expiry_drop);
|
||||
let (expiry_row, expiry_drop) = dropdown_row(&gettext("Expires"), &expiry_names, 0);
|
||||
let create = group_button(&gettext("Create Public Link"));
|
||||
|
||||
// Enter in the password field creates the link.
|
||||
@@ -734,6 +790,142 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
*state.link_rows.borrow_mut() = rows;
|
||||
}
|
||||
|
||||
/// The settings of an existing link, collapsed under one row. Each control
|
||||
/// applies at once, the way a member's role does. While a change is on its way
|
||||
/// the whole row is insensitive: the daemon fills in what a change keeps from
|
||||
/// the link as it stands, so two changes in flight could undo each other.
|
||||
fn link_settings_row(state: &Rc<ShareDialog>, link: &PublicLinkInfo) -> adw::ExpanderRow {
|
||||
let now = glib::real_time() / 1_000_000;
|
||||
let expander = adw::ExpanderRow::builder()
|
||||
.title(gettext("Link settings"))
|
||||
.subtitle(gettext(
|
||||
"Change who can edit, the password or the expiry. The link stays the same.",
|
||||
))
|
||||
.build();
|
||||
|
||||
let selected_role = u32::from(link.role == "editor");
|
||||
let (role_row, role_drop) = dropdown_row(&gettext("Link role"), &link_roles(), selected_role);
|
||||
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
|
||||
role_drop.connect_selected_notify(move |d| {
|
||||
let Some(state) = state_w.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let role = if d.selected() == 1 {
|
||||
"editor"
|
||||
} else {
|
||||
"viewer"
|
||||
};
|
||||
let request = state.target.update_link(
|
||||
id.clone(),
|
||||
Some(role.into()),
|
||||
LinkChange::Keep,
|
||||
LinkChange::Keep,
|
||||
);
|
||||
share_dialog_update_link(&state, request, &expander_w);
|
||||
});
|
||||
expander.add_row(&role_row);
|
||||
|
||||
let current = link.expires;
|
||||
let (expiry_row, expiry_drop) =
|
||||
dropdown_row(&gettext("Expires"), &link_expiry_choices(current, now), 0);
|
||||
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
|
||||
expiry_drop.connect_selected_notify(move |d| {
|
||||
let Some(state) = state_w.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let expires = link_expiry_change(d.selected(), current, glib::real_time() / 1_000_000);
|
||||
if expires == LinkChange::Keep {
|
||||
return;
|
||||
}
|
||||
let request = state
|
||||
.target
|
||||
.update_link(id.clone(), None, LinkChange::Keep, expires);
|
||||
share_dialog_update_link(&state, request, &expander_w);
|
||||
});
|
||||
expander.add_row(&expiry_row);
|
||||
|
||||
let pw_row = adw::PasswordEntryRow::builder()
|
||||
.title(if link.has_password {
|
||||
gettext("New password")
|
||||
} else {
|
||||
gettext("Add a password")
|
||||
})
|
||||
.show_apply_button(true)
|
||||
.build();
|
||||
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
|
||||
pw_row.connect_apply(move |row| {
|
||||
let Some(state) = state_w.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let password = row.text().to_string();
|
||||
if password.is_empty() {
|
||||
return;
|
||||
}
|
||||
let request = state.target.update_link(
|
||||
id.clone(),
|
||||
None,
|
||||
LinkChange::Set(password),
|
||||
LinkChange::Keep,
|
||||
);
|
||||
share_dialog_update_link(&state, request, &expander_w);
|
||||
});
|
||||
expander.add_row(&pw_row);
|
||||
|
||||
if link.has_password {
|
||||
let remove = gtk4::Button::builder()
|
||||
.label(gettext("Remove"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
remove.add_css_class("flat");
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(gettext("Remove the password"))
|
||||
.subtitle(gettext(
|
||||
"Anyone with the link can open it without a password.",
|
||||
))
|
||||
.build();
|
||||
row.add_suffix(&remove);
|
||||
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
|
||||
remove.connect_clicked(move |_| {
|
||||
let Some(state) = state_w.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let request =
|
||||
state
|
||||
.target
|
||||
.update_link(id.clone(), None, LinkChange::Remove, LinkChange::Keep);
|
||||
share_dialog_update_link(&state, request, &expander_w);
|
||||
});
|
||||
expander.add_row(&row);
|
||||
}
|
||||
expander
|
||||
}
|
||||
|
||||
/// Send a link change, then reload the dialog: on success to show the link as
|
||||
/// it now is, on failure to put the controls back to what the link still is.
|
||||
fn share_dialog_update_link(
|
||||
state: &Rc<ShareDialog>,
|
||||
request: Request,
|
||||
settings: &adw::ExpanderRow,
|
||||
) {
|
||||
settings.set_sensitive(false);
|
||||
let rx = spawn_request(state.ui.dirs.control_socket(), request);
|
||||
let state = state.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::PublicLink { .. })) => toast(&state.ui, &gettext("Link updated")),
|
||||
Ok(Ok(Response::Error { message, .. })) => {
|
||||
toast_error(&state.ui, &gettext("Couldn't change the link"), &message)
|
||||
}
|
||||
_ => toast_error(
|
||||
&state.ui,
|
||||
&gettext("Couldn't change the link"),
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
share_dialog_reload(&state);
|
||||
});
|
||||
}
|
||||
|
||||
/// Create a public link, then reload the dialog so the copy/remove controls
|
||||
/// replace the create form (and the freshly minted URL is shown).
|
||||
pub(crate) fn share_dialog_create_link(
|
||||
@@ -863,6 +1055,28 @@ mod tests {
|
||||
assert_eq!(link_expiry_at(99, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_an_expiry_keeps_removes_or_sets_it() {
|
||||
let now = 1_000;
|
||||
// No expiry yet: the choices are just Never and the lifetimes.
|
||||
assert_eq!(link_expiry_choices(None, now).len(), LINK_EXPIRY.len());
|
||||
assert_eq!(link_expiry_change(0, None, now), LinkChange::Keep);
|
||||
assert_eq!(
|
||||
link_expiry_change(2, None, now),
|
||||
LinkChange::Set(now + 7 * 86_400)
|
||||
);
|
||||
|
||||
// An expiry set: it comes first, then Never and the lifetimes.
|
||||
let at = Some(2_000_000_000);
|
||||
assert_eq!(link_expiry_choices(at, now).len(), LINK_EXPIRY.len() + 1);
|
||||
assert_eq!(link_expiry_change(0, at, now), LinkChange::Keep);
|
||||
assert_eq!(link_expiry_change(1, at, now), LinkChange::Remove);
|
||||
assert_eq!(
|
||||
link_expiry_change(2, at, now),
|
||||
LinkChange::Set(now + 86_400)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn past_expiry_reads_expired() {
|
||||
assert_eq!(link_expiry_label(10, 20), "expired");
|
||||
|
||||
+483
@@ -68,6 +68,17 @@ original wording.
|
||||
|
||||
| Entry | Title | What is left |
|
||||
|---|---|---|
|
||||
| [B273](#b273--a-restore-proposes-another-computers-paths-when-the-two-share-a-hostname) | A restore proposes another computer's paths when the two share a hostname | Not fixed |
|
||||
| [B271](#b271--one-failed-listing-makes-a-large-first-sync-start-over-from-nothing) | One failed listing makes a large first sync start over from nothing | Not fixed |
|
||||
| [B270](#b270--one-file-left-in-a-mirror-folder-clears-the-wipe-guard-for-all-the-others) | One file left in a mirror folder clears the wipe guard for all the others | Not fixed |
|
||||
| [B269](#b269--a-restore-that-skipped-every-folder-reports-success) | A restore that skipped every folder reports success | Not fixed |
|
||||
| [B268](#b268--pdfs-diagnose-says-no-problems-found-while-sync-is-stuck) | `pdfs diagnose` says "No problems found" while sync is stuck | Not fixed |
|
||||
| [B267](#b267--a-key-failure-at-a-start-from-a-warm-cache-shows-as-offline-for-good) | A key failure at a start from a warm cache shows as offline for good | Not fixed |
|
||||
| [B266](#b266--pin-unpin-search-and-node-loads-report-a-database-error-as-offline) | Pin, unpin, search and node loads report a database error as "offline" | Not fixed |
|
||||
| [B265](#b265--address-keys-whose-token-is-for-an-inactive-account-key-never-open) | Address keys whose token is for an inactive account key never open | Needs an SDK change; not reproduced |
|
||||
| [B264](#b264--a-mount-that-fails-after-starting-its-workers-leaves-them-running) | A mount that fails after starting its workers leaves them running | Not fixed |
|
||||
| [B263](#b263--a-mount-that-can-never-succeed-retries-every-5-s-and-tells-only-the-journal) | A mount that can never succeed retries every 5 s and tells only the journal | Not fixed |
|
||||
| [B261](#b261--a-folder-that-has-never-synced-reads-up-to-date) | A folder that has never synced reads "up to date" | Not fixed |
|
||||
| [B134](#b134--a-file-deleted-online-while-the-drain-checks-its-write-is-kept-as-a-conflict-copy) | A file deleted online while the drain checks its write is kept as a conflict copy | Only with `local_first` off |
|
||||
| [B91](#b91--the-daemon-hung-overnight-and-nothing-could-say-what-it-was-waiting-on) | The daemon hung overnight, and nothing could say what it was waiting on | The cause is not diagnosed; the daemon now reports where it is stuck and is restarted |
|
||||
| [B67](#b67--fresh-state-restore-can-silently-select-a-new-device-high-22) | Fresh-state restore can silently select a new device (HIGH-22) | An ambiguous device after a loss of local state is not asked about |
|
||||
@@ -80,6 +91,8 @@ original wording.
|
||||
|
||||
| Entry | Title | What is left |
|
||||
|---|---|---|
|
||||
| [B262](#b262--watching-a-large-synced-folder-holds-up-the-sync-engine-and-a-failed-watch-is-never-retried) | Watching a large synced folder holds up the sync engine, and a failed watch is never retried | The first pass still waits for the watch; a failed watch is not shown in the app |
|
||||
| [B259](#b259--the-sync-engine-can-die-or-hang-and-nothing-shows-it-until-a-restart) | The sync engine can die or hang, and nothing shows it until a restart | No pass deadline; an ended engine is reported, not restarted; the trigger in #53 is not known |
|
||||
| [B199](#b199--a-stopped-share-stays-on-the-shared-by-me-page-for-minutes) | A stopped share stays on the Shared by me page for minutes | The share itself is not deleted; the SDK has no call for it |
|
||||
| [B88](#b88--pdfs-pin-rename-move-delete-and-sharing-still-cannot-name-a-path-under-a-secondary-mount) | `pdfs pin`, `rename`, `move`, `delete` and sharing still cannot name a path under a secondary mount | `pin`, `unpin`, opening a file, thumbnails, uploads, sharing and versions |
|
||||
| [B72](#b72--pdfs-trash-never-returns) | `pdfs trash` never returns | The slowness of the first refresh is unmeasured |
|
||||
@@ -100,6 +113,11 @@ a live account since.
|
||||
|
||||
| Entry | Title |
|
||||
|---|---|
|
||||
| [B272](#b272--after-a-folder-is-removed-and-added-again-its-file-events-go-to-the-old-id) | After a folder is removed and added again, its file events go to the old id |
|
||||
| [B260](#b260--the-poll-pushes-a-new-folders-first-sync-behind-every-other-folder) | The poll pushes a new folder's first sync behind every other folder |
|
||||
| [B258](#b258--journalctl--p-warning-shows-nothing-whatever-the-daemon-logged) | `journalctl -p warning` shows nothing, whatever the daemon logged |
|
||||
| [B256](#b256--pdfs-copy-right-after-a-save-copies-the-old-version) | `pdfs copy` right after a save copies the old version |
|
||||
| [B255](#b255--a-synced-folder-whose-files-are-all-gone-here-is-stuck-for-good) | A synced folder whose files are all gone here is stuck for good |
|
||||
| [B254](#b254--an-edit-in-the-same-second-as-a-new-upload-never-arrives) | An edit in the same second as a new upload never arrives |
|
||||
| [B253](#b253--the-app-shows-the-sign-in-page-when-the-keyring-is-locked) | The app shows the sign-in page when the keyring is locked |
|
||||
| [B252](#b252--computers-sharing-a-device-overwrite-each-others-profile) | Computers sharing a device overwrite each other's profile |
|
||||
@@ -357,6 +375,458 @@ a live account since.
|
||||
|
||||
Newest first.
|
||||
|
||||
### B273 — A restore proposes another computer's paths when the two share a hostname
|
||||
|
||||
**Status:** Open
|
||||
**Found:** 2026-10-31, code audit after issue #46.
|
||||
**Where:** `saved_folder` in `crates/pdfs-core/src/profile.rs`
|
||||
|
||||
**Repro:** Two computers with the same hostname, for example both left at a distribution's
|
||||
default `archlinux`, sync the same device. Restore a folder on one of them. The proposed local
|
||||
path and mode come from whichever of the two profiles is found first, not from this computer's.
|
||||
|
||||
**Cause.** `saved_folder` prefers the profile whose `hostname` matches. The machine id that
|
||||
B252 added tells computers apart, but this lookup does not use it.
|
||||
|
||||
**Fix.** Not done. Match on the machine id first, then fall back to the hostname for profiles
|
||||
written before it existed.
|
||||
|
||||
---
|
||||
|
||||
### B272 — After a folder is removed and added again, its file events go to the old id
|
||||
|
||||
**Status:** Fixed, not verified live
|
||||
**Found:** 2026-10-31, code audit after issue #53.
|
||||
**Where:** `rewatch` in `crates/pdfs-fuse/src/sync/engine.rs`
|
||||
|
||||
**Repro:** Remove a synced folder and add the same path again before the engine handles the
|
||||
first `Rewatch`, so both land in one burst. Edits in the folder are not synced until the
|
||||
two-minute poll, and the same goes for every later edit until the daemon restarts.
|
||||
|
||||
**Cause.** `rewatch` matches the watches it has against the ones it wants by path alone. The
|
||||
path is still wanted, so the old entry stays with the removed folder's id. Its events become
|
||||
`Reconcile(old id)`, `sync_folder_get` finds no row, and the request is dropped.
|
||||
|
||||
**Fix.** `keep_wanted` keeps a watch whose path is still wanted and gives it the id the
|
||||
folder list has now.
|
||||
|
||||
**Test:** `a_re_added_folder_keeps_its_watch_under_the_new_id` in `pdfs-fuse`.
|
||||
|
||||
---
|
||||
|
||||
### B271 — One failed listing makes a large first sync start over from nothing
|
||||
|
||||
**Status:** Open
|
||||
**Found:** 2026-10-31, code audit after issue #46: a folder of about 19,900 items restored on a
|
||||
second computer sat at `scanning…` with nothing downloaded, and the journal showed
|
||||
`RequestTimeout (http 408)`.
|
||||
**Where:** `walk_remote` in `crates/pdfs-fuse/src/sync.rs`
|
||||
|
||||
**Repro:** Restore or add a mirror folder with tens of thousands of items on Drive, on a link
|
||||
that drops a request now and then. The pass walks the whole remote tree before it downloads
|
||||
anything. When one folder's listing fails after the SDK's retries, the pass fails, and the
|
||||
next one walks the whole tree again. On a slow enough link it never gets past the walk.
|
||||
|
||||
**Cause.** Each `enumerate_folder_children_node_uids`, `enumerate_nodes_light` and
|
||||
`enumerate_nodes` call in the walk ends the whole walk through `?`. Nothing is applied until
|
||||
the walk is done. Files that did download keep their baseline rows, so only the walk is lost,
|
||||
but on a first sync that is all of it. Progress is also counted only after a folder's full
|
||||
listing, so a large folder shows `scanning…` with 0 checked for a long time.
|
||||
|
||||
**Fix.** Not done. Record a failed subtree as a gap, as undecryptable nodes already are: no
|
||||
deletions under it, and the rest of the pass still applies.
|
||||
|
||||
---
|
||||
|
||||
### B270 — One file left in a mirror folder clears the wipe guard for all the others
|
||||
|
||||
**Status:** Open
|
||||
**Found:** 2026-10-31, code audit after issue #46, whose reporter emptied a synced folder
|
||||
several times.
|
||||
**Where:** `guard_local_wipe` and `guard_remote_wipe` in `crates/pdfs-fuse/src/sync/planner.rs`
|
||||
|
||||
**Repro:** Sync a mirror folder with many files. Make all of them but one unavailable here: a
|
||||
half-mounted disk, a restored backup that brought back one file, or a script that emptied the
|
||||
folder and wrote one file back. The next pass trashes every other file on Drive. On a device
|
||||
that two computers sync, the other computer then deletes its copies too.
|
||||
|
||||
**Cause.** The guard refuses a pass only when every baseline path is missing. One surviving
|
||||
path is enough to clear it, so it guards against an empty folder and nothing short of that.
|
||||
The remote guard has the same shape.
|
||||
|
||||
**Fix.** Not done. Also refuse when most of a large baseline is missing at once, and leave the
|
||||
choice to the user: delete on the other side, or sync from scratch.
|
||||
|
||||
---
|
||||
|
||||
### B269 — A restore that skipped every folder reports success
|
||||
|
||||
**Status:** Open
|
||||
**Found:** 2026-10-31, code audit after issue #46: the journal showed `restore complete
|
||||
restored=0 skipped=1` several times, and the user saw nothing.
|
||||
**Where:** the `RestoreSyncFolders` and `RestoreDeviceFolders` handler in
|
||||
`crates/pdfs-fuse/src/control.rs`; `restore_sync_folders` in `crates/pdfs-fuse/src/profile.rs`
|
||||
|
||||
**Repro:** Restore a folder from another computer to a path that is already a sync root here,
|
||||
for example a folder left over from an earlier attempt. `pdfs devices join` prints `restoring
|
||||
folders`, and the app shows the same. Nothing downloads, and no error appears anywhere.
|
||||
|
||||
**Cause.** The daemon acks before it works, because a restore outlasts the socket timeout. The
|
||||
result, `restored 0 folder(s); skipped: …`, is then logged as a successful activity, so the
|
||||
reason is only in the activity text and the journal. When the database insert fails partway,
|
||||
the `?` also skips the `Rewatch` for the folders already restored.
|
||||
|
||||
**Fix.** Not done. Log a restore that skipped folders as a failure, name the reason in the app
|
||||
and the CLI, and offer to replace or reset the folder in the way. Send the `Rewatch` on every
|
||||
exit path.
|
||||
|
||||
---
|
||||
|
||||
### B268 — `pdfs diagnose` says "No problems found" while sync is stuck
|
||||
|
||||
**Status:** Open
|
||||
**Found:** 2026-10-31, issue #53: a folder that never synced, and `diagnose` found nothing.
|
||||
**Where:** `diagnose` in `crates/pdfs-cli/src/main.rs`
|
||||
|
||||
**Repro:** Leave a synced folder in `error`, or have queued writes that keep failing, or pause
|
||||
sync. `pdfs diagnose` reports every check `[ok]` and exits 0.
|
||||
|
||||
**Cause.** The `Status` reply is matched with `..`, which drops `failing_ops`, `failing_error`,
|
||||
`parked_uploads`, `staged_oldest_secs` and `paused`. `diagnose` never asks for the synced
|
||||
folders or for `Diagnostics`, so a folder in error and a stalled worker are not seen. A
|
||||
control socket file with no daemon behind it is also reported `[ok]`; only the next line says
|
||||
the daemon does not answer.
|
||||
|
||||
**Fix.** Not done. Warn on failing or parked writes, a pause, old staged data and folders in
|
||||
error, and report the sync engine's state once B259 exposes it.
|
||||
|
||||
---
|
||||
|
||||
### B267 — A key failure at a start from a warm cache shows as offline for good
|
||||
|
||||
**Status:** Open
|
||||
**Found:** 2026-10-31, code audit after issue #40. B202 fixed the same path for a refused
|
||||
session only.
|
||||
**Where:** `fetch_or_recall_root` in `crates/pdfs-fuse/src/mount.rs`; `run_online_probe` in
|
||||
`crates/pdfs-fuse/src/link.rs`
|
||||
|
||||
**Repro:** Sign in once so the cache holds the My Files root. Then start the daemon in a state
|
||||
where the root cannot be decrypted, for example the address-key failure of B265. The mount
|
||||
comes up "offline", the app says you are offline, and it stays so while the network is fine.
|
||||
|
||||
**Cause.** Any error from `get_my_files_folder` other than a revoked session becomes
|
||||
`Start::Offline` when a cached root exists. The online probe then retries the same call and
|
||||
logs its failures at debug level, so the journal at the default level says nothing.
|
||||
|
||||
**Fix.** Not done. Use `Start::Offline` only for an error `is_network_error` accepts, and
|
||||
report the others as their own state through `Status`.
|
||||
|
||||
---
|
||||
|
||||
### B266 — Pin, unpin, search and node loads report a database error as "offline"
|
||||
|
||||
**Status:** Open
|
||||
**Found:** 2026-10-31, code audit after issue #38. B205 fixed the photo upload path only.
|
||||
**Where:** `CoreError::from_api` in `crates/pdfs-core/src/error.rs`; its callers in
|
||||
`crates/pdfs-fuse/src/lib.rs` (`pin`, `unpin`, the searches, `load node`)
|
||||
|
||||
**Repro:** Pin a folder while the cache database is locked or full. The app says "You're
|
||||
offline" and drops the reason.
|
||||
|
||||
**Cause.** `from_api` classifies any error that is not a `ProtonError` as `Offline`, because
|
||||
it was written for SDK calls, where that means a transport error. These callers pass it
|
||||
`pdfs_core::Error` values from the cache and the database. A `ProtonError` wrapped in
|
||||
`pdfs_core::Error::Proton` also misses the downcast, since the source chain is not walked.
|
||||
|
||||
**Fix.** Not done. Use `CoreError::internal` at the database callers, and walk the source
|
||||
chain in `from_api` before falling back.
|
||||
|
||||
---
|
||||
|
||||
### B265 — Address keys whose token is for an inactive account key never open
|
||||
|
||||
**Status:** Open
|
||||
**Found:** 2026-10-31, code audit after issue #40: a Duo account whose mount failed on every
|
||||
start with `fetch My Files root: cryptography error: failed to decrypt: missing key`, after three
|
||||
`failed to decrypt address key token` warnings. The web app worked. B210's fix does not apply,
|
||||
because the account has an active volume.
|
||||
**Where:** `user_keys` and `decrypt_address` in `proton-sdk` (`crates/proton-sdk/src/account.rs`
|
||||
in `proton-sdk-rs`)
|
||||
|
||||
**Repro:** Not reproduced. An account whose address-key tokens are encrypted to an account key
|
||||
that is no longer active cannot open those address keys, and so cannot open the My Files share.
|
||||
|
||||
**Cause.** `user_keys` skips every inactive account key, even one the password opens, and the
|
||||
address-key tokens are decrypted only with what it returns. `keycheck.rs` in this repository
|
||||
already reports "token is for inactive account key", so the case is known. Separately,
|
||||
`addresses()` fails as a whole through `?` when one address has no usable primary key, which
|
||||
takes the working addresses down with it.
|
||||
|
||||
**Fix.** Not done, and it belongs in the SDK. Use every account key that opens for decrypting
|
||||
tokens, and only the active ones for signing. Skip and log an address that cannot be opened.
|
||||
|
||||
---
|
||||
|
||||
### B264 — A mount that fails after starting its workers leaves them running
|
||||
|
||||
**Status:** Open
|
||||
**Found:** 2026-10-31, code audit after issue #40.
|
||||
**Where:** `run_mount` in `crates/pdfs-fuse/src/mount.rs`
|
||||
|
||||
**Repro:** Make the mount fail after the workers start: a busy mountpoint, or a control socket
|
||||
whose permissions cannot be set. The daemon retries every 5 s, and the drain, sync, sweep,
|
||||
probe and index threads of the failed attempt keep running beside the next one.
|
||||
|
||||
**Cause.** The early returns after the workers are spawned (the `?` on the socket bind, the
|
||||
permission check, a `spawn_session` error, and a `?` on a later thread spawn) skip
|
||||
`stop_workers`. The threads hold a `Core`, and the `Core` holds the database. If that keeps
|
||||
the `flock` held, every later attempt fails with "another daemon is using cache.db" instead
|
||||
of the real error. That part is inferred from the code and not observed.
|
||||
|
||||
**Fix.** Not done. Bind the socket and check the mountpoint before any worker starts, and stop
|
||||
the workers on every error path.
|
||||
|
||||
---
|
||||
|
||||
### B263 — A mount that can never succeed retries every 5 s and tells only the journal
|
||||
|
||||
**Status:** Open
|
||||
**Found:** 2026-10-31, issue #40: the app showed "Connecting… waiting for the Proton Drive
|
||||
service" for days, `pdfs diagnose` only reported the control socket missing, and the reporter
|
||||
uninstalled.
|
||||
**Where:** the mount loop in `crates/pdfs-cli/src/main.rs`; `run_mount` in
|
||||
`crates/pdfs-fuse/src/mount.rs`
|
||||
|
||||
**Repro:** Start the daemon with a session whose keys cannot open My Files (B265). The journal
|
||||
shows `mount failed; retrying in 5s` forever. The app and the tray wait for the service, and
|
||||
nothing says why.
|
||||
|
||||
**Cause.** Every mount error gets the same 5 s retry, whether it is a network error or one that
|
||||
no retry fixes, such as a key or a 4xx answer. Each attempt builds a new client and sends about
|
||||
four requests, so a broken account sends tens of thousands a day, which may also feed
|
||||
Proton's sign-in blocks (#39). The control socket is bound only after the mount succeeds, and
|
||||
the error is written nowhere a front end can read it.
|
||||
|
||||
**Fix.** Not done. Back off exponentially, treat key, `KeysLocked` and 4xx errors as permanent
|
||||
with a long back-off, and write the last error where the app, the tray and `diagnose` can
|
||||
show it (a state file, and `STATUS=` to systemd).
|
||||
|
||||
---
|
||||
|
||||
### B262 — Watching a large synced folder holds up the sync engine, and a failed watch is never retried
|
||||
|
||||
**Status:** Partly fixed. A watch is still set up before the first pass, and a failed watch
|
||||
is not shown in the folder's state.
|
||||
**Found:** 2026-10-31, code audit after issue #53.
|
||||
**Where:** `rewatch` and `engine_loop` in `crates/pdfs-fuse/src/sync/engine.rs`
|
||||
|
||||
**Repro:** Add a synced folder with many subfolders, or one that holds a symlink to a large
|
||||
tree, on a system near `fs.inotify.max_user_watches`. The new folder's first pass waits until
|
||||
the recursive watch is set up. When the watch fails, the journal has one `sync: watch failed`
|
||||
line, the folder is never watched, and later changes are found only by the two-minute poll.
|
||||
|
||||
**Cause.**
|
||||
- `rewatch` runs on the engine thread before the reconcile, and notify's recursive watch walks
|
||||
the whole tree, at startup for every folder.
|
||||
- notify follows symlinks by default, and the local walk does not.
|
||||
- When `watch` fails partway, the watches added before the failure stay registered, but the
|
||||
folder is not recorded as watched. Their events match no folder and become `ReconcileAll`,
|
||||
and they use up watches the next folder needs.
|
||||
- Errors the watcher reports later are dropped in its callback.
|
||||
|
||||
**Fix.**
|
||||
- A failed watch is unwatched, which removes the watches it had added under the folder, and
|
||||
is tried again every 10 minutes. The warning names `fs.inotify.max_user_watches`.
|
||||
- The watcher no longer follows symlinks.
|
||||
- Errors the watcher reports are logged.
|
||||
- Not done: the watch still runs before the folder's first pass. Watching first is what keeps
|
||||
a change made during the pass from being missed, so the order stays; the cost is one walk
|
||||
of a new folder's tree. A failed watch is not shown in the folder's state yet.
|
||||
|
||||
**Test:** none for the watch failure itself; it needs the inotify limit reached.
|
||||
|
||||
---
|
||||
|
||||
### B261 — A folder that has never synced reads "up to date"
|
||||
|
||||
**Status:** Open
|
||||
**Found:** 2026-10-31, issue #53: "In Computers I add the folder. It appears as synced."
|
||||
**Where:** `sync_folder` in `crates/pdfs-core/src/db/migrations.rs`; `sync_state_label` in
|
||||
`crates/pdfs-gui/src/app/pages/devices.rs`; the location rows in
|
||||
`crates/pdfs-gui/src/app/pages/locations.rs`
|
||||
|
||||
**Repro:** Add a synced folder. Until its first pass starts, the app shows it as up to date.
|
||||
The same happens after a switch from on-demand to mirror, after **Sync From Scratch…**, and for
|
||||
every folder while sync is paused for the whole account.
|
||||
|
||||
**Cause.** The `state` column defaults to `idle`, and every state the label does not know reads
|
||||
"up to date". `last_sync` is 0 for a folder that has never synced, but the rows do not look at
|
||||
it, and a test pins the label for that case. The rows only show a folder's own pause, not the
|
||||
account-wide one.
|
||||
|
||||
**Fix.** Not done. Show "waiting for first sync" when `last_sync` is 0, and show the
|
||||
account-wide pause on the folder rows.
|
||||
|
||||
---
|
||||
|
||||
### B260 — The poll pushes a new folder's first sync behind every other folder
|
||||
|
||||
**Status:** Fixed, not verified live
|
||||
**Found:** 2026-10-31, code audit after issue #53.
|
||||
**Where:** `engine_loop` in `crates/pdfs-fuse/src/sync/engine.rs`
|
||||
|
||||
**Repro:** Sync a few large mirror folders whose passes take longer than two minutes, then add
|
||||
another folder. Its first pass waits until every other folder has been walked again, each
|
||||
time.
|
||||
|
||||
**Cause.** The poll sends `ReconcileAll` every two minutes whether or not a pass is still
|
||||
running. When a burst holds both a `ReconcileAll` and a folder's `Reconcile(id)`, the loop runs
|
||||
only `reconcile_all`, which goes in id order. A new folder has the highest id, so it always
|
||||
goes last.
|
||||
|
||||
**Fix.** `run_burst` runs the folders a burst names by id first, lowest id first, and then
|
||||
every other folder when the burst also holds a `ReconcileAll`. A named folder is read again
|
||||
before its pass. A pause that lands between passes now stops the rest of the burst.
|
||||
|
||||
**Test:** `named_folders_run_before_the_rest_of_a_burst` in `pdfs-fuse`.
|
||||
|
||||
---
|
||||
|
||||
### B259 — The sync engine can die or hang, and nothing shows it until a restart
|
||||
|
||||
**Status:** Partly fixed. A hung pass is reported but not ended, and a panic outside a pass
|
||||
still stops the engine; it is reported, not restarted.
|
||||
**Found:** 2026-10-31, issue #53: a newly added folder did not sync until the reporter restarted
|
||||
the app. All FUSE workers were idle, nothing was queued, and the warning-level journal was
|
||||
empty (B258).
|
||||
**Where:** `spawn` and `engine_loop` in `crates/pdfs-fuse/src/sync/engine.rs`;
|
||||
`reconcile_pass` in `crates/pdfs-fuse/src/sync.rs`; `crates/pdfs-fuse/src/diagnostics.rs`
|
||||
|
||||
**Repro:** Not reproduced. Make one pass panic, or block in a call that does not return. Every
|
||||
later add, `pdfs sync now`, file event and poll is dropped without a word. The folder that was
|
||||
syncing stays `syncing` with its last progress. A restart runs a pass over every folder, which
|
||||
is why it helps.
|
||||
|
||||
**Cause.**
|
||||
- The engine is one thread, and nothing catches a panic in a pass. When it unwinds, the
|
||||
receiver is gone and every `let _ = sync_tx.send(..)` fails silently. The poll thread stops
|
||||
at its first failed send.
|
||||
- The thread's `JoinHandle` is checked only at shutdown.
|
||||
- A pass has no overall deadline. Each request is bounded, but a pass is not; B165 dealt with
|
||||
the stop only.
|
||||
- Nothing logs a pass starting, or ending without changes. `Diagnostics` covers the FUSE
|
||||
workers and the request queue, not this thread, so "workers idle, nothing queued" says
|
||||
nothing about sync.
|
||||
- A database error when the engine reads a folder drops that folder's request without a log.
|
||||
|
||||
**Fix.**
|
||||
- Each pass runs under `catch_unwind` (`EngineHealth::contain` in
|
||||
`crates/pdfs-fuse/src/sync/engine.rs`). A panic ends that pass only: its progress is cleared,
|
||||
the folder is set to `error`, the activity feed says so, and the next poll tries again.
|
||||
- `Diagnostics` carries the engine's state: running or not, the folder of the running pass, how
|
||||
long it has run and how long since it last reported progress, and how many passes panicked.
|
||||
`pdfs diagnostics` prints it as `sync engine: …`.
|
||||
- The supervisor warns when the engine thread has ended, and when a pass has reported no
|
||||
progress for 15 minutes.
|
||||
- A first pass logs its start and end at info, as does any pass that changed something or
|
||||
took 30 s or more. A request for a folder the database cannot read is logged.
|
||||
- Not done: a deadline for a pass, and restarting an engine thread that has ended. The
|
||||
channel's receiver ends with the thread, so a restart needs a new channel in every `Core`
|
||||
clone.
|
||||
|
||||
**Test:** `a_panicking_pass_is_contained_and_counted`, `the_running_pass_is_reported` and
|
||||
`the_engine_reads_as_stopped_once_its_thread_ends` in `pdfs-fuse`;
|
||||
`a_stopped_sync_engine_is_a_stall` and `only_a_quiet_sync_pass_is_a_stall` in the supervisor.
|
||||
|
||||
---
|
||||
|
||||
### B258 — `journalctl -p warning` shows nothing, whatever the daemon logged
|
||||
|
||||
**Status:** Fixed, not verified live
|
||||
**Found:** 2026-10-31, issue #53: the reporter ran `journalctl --user -u proton-drive.service -b
|
||||
-p warning`, as `docs/TROUBLESHOOTING.md` tells them to, and got `-- No entries --`.
|
||||
**Where:** `main` in `crates/pdfs-cli/src/main.rs`; `packaging/proton-drive.service`
|
||||
|
||||
**Repro:** Make the daemon log a warning, then run the command above. It prints no entries.
|
||||
Without `-p` the warning is there.
|
||||
|
||||
**Cause.** The daemon logs with plain `tracing_subscriber::fmt()` to standard output. journald
|
||||
stores every line of a service's output at the unit's default priority, info, unless the line
|
||||
starts with a `<N>` prefix. So every `WARN` and `ERROR` line is stored as info, and the
|
||||
troubleshooting guide's filter hides all of them.
|
||||
|
||||
**Fix.** When standard output is the journal stream (`JOURNAL_STREAM` names the same
|
||||
device and inode as fd 1), each line starts with the `<N>` prefix for its level, and ANSI colours
|
||||
are off. A terminal that merely inherited `JOURNAL_STREAM` keeps the plain format
|
||||
(`crates/pdfs-cli/src/journal.rs`).
|
||||
|
||||
**Test:** `each_line_opens_with_the_priority_of_its_level` and
|
||||
`journal_stream_must_name_the_same_file` in `pdfs-cli`. On 2026-10-31, `pdfs diagnose` run with
|
||||
`RUST_LOG=debug` under `systemd-run --user -p StandardOutput=journal` stored its debug lines at
|
||||
`PRIORITY=7` and its report at 6.
|
||||
|
||||
---
|
||||
|
||||
### B256 — `pdfs copy` right after a save copies the old version
|
||||
|
||||
**Status:** Fixed, not verified live
|
||||
**Found:** 2026-10-26, the simulation `a_pdfs_copy_copies_on_drive_and_waits_for_unsynced_changes`
|
||||
failing on most runs. The feature was not released yet.
|
||||
**Where:** `release` in `crates/pdfs-fuse/src/filesystem.rs`; `copy_to` in
|
||||
`crates/pdfs-fuse/src/lib.rs`; `has_pending_work_under` in `crates/pdfs-fuse/src/relocate.rs`
|
||||
|
||||
**Repro:** Save a file in the mount and copy it, or its folder, with `pdfs copy` straight away.
|
||||
The copy goes ahead and Drive copies the version before the save.
|
||||
|
||||
**Cause.** `close(2)` returns before the kernel sends `release`. `release` takes the write handle
|
||||
out of the open writes and then queues the revision, which takes a few milliseconds. A copy
|
||||
checked in between found the edit in neither place. A folder copy looked only at the queue, so
|
||||
it also missed a file still open with unsaved writes.
|
||||
|
||||
**Fix.** `release` keeps the inode in `State::releasing` until the revision is queued. A copy
|
||||
refuses a file or folder with unsaved writes, or a release still queueing, at it or below it.
|
||||
A move checks the same set.
|
||||
|
||||
**Test:** The simulation above passed 6 of 6 runs after the fix.
|
||||
|
||||
---
|
||||
|
||||
### B255 — A synced folder whose files are all gone here is stuck for good
|
||||
|
||||
**Status:** Fixed, not verified live
|
||||
**Found:** 2026-10-26, issue #46: a folder restored from another computer never downloaded.
|
||||
**Where:** `push_pass` in `crates/pdfs-fuse/src/sync.rs`; `reset_sync_folder` in
|
||||
`crates/pdfs-fuse/src/devices.rs`
|
||||
|
||||
**Repro:** Sync a folder in mirror mode, then delete all of its files on this computer while
|
||||
the folder is paused. Resume it. Every pass stops with `every one of the N synced paths is
|
||||
missing locally`, the folder stays in error, and a switch to online only waits for good.
|
||||
|
||||
**Cause.** The wipe guard is right to stop a pass that would trash the whole folder on Drive.
|
||||
But nothing got the folder out of that state. A switch to on-demand waits for a pass that ends
|
||||
`idle`, and its push pass hit the same guard. The baseline that made the empty folder read as
|
||||
"delete everything" could only be cleared by removing the folder from sync. How the reporter's
|
||||
folder lost its files is not known yet.
|
||||
|
||||
**Fix.** The push pass for a switch to on-demand forgets the baseline rows of a local copy that
|
||||
is gone in full, instead of trashing them on Drive, so the switch goes ahead and the mount shows
|
||||
what Drive has. A switch asked for before any pass saw the folder empty found it still `idle`,
|
||||
and the check for what only this computer has refused it at once: every path read as "deleted
|
||||
here but not on Proton Drive yet". The account run found that. Such a switch now waits for the
|
||||
same push pass (`mirror_tree_gone` in `crates/pdfs-fuse/src/sync.rs`, checked in
|
||||
`apply_sync_folder_mode`). **Sync From Scratch…** in the app and `pdfs sync reset ID` clear a mirror
|
||||
folder's baseline, so the next pass compares both sides as on the first sync: one-sided files
|
||||
are copied across, files that differ keep both versions, and nothing is deleted. Restore now
|
||||
logs why it skipped a folder.
|
||||
|
||||
**Test:** The simulations `a_folder_whose_local_copy_is_gone_still_goes_on_demand` (which
|
||||
failed in exactly the reported state before the fix),
|
||||
`a_folder_emptied_before_any_pass_still_goes_on_demand` (which failed with the account run's
|
||||
error) and `a_folder_whose_local_copy_is_gone_downloads_again_after_a_reset`; the acceptance case
|
||||
"an emptied mirror folder switches to on-demand".
|
||||
|
||||
---
|
||||
|
||||
### B254 — An edit in the same second as a new upload never arrives
|
||||
|
||||
**Status:** Fixed, not verified live
|
||||
@@ -1461,6 +1931,8 @@ what it holds. What was synced in it is still deleted.
|
||||
### B210 — After a password reset, the mount retries "missing key" forever
|
||||
|
||||
**Status:** Fixed, not verified live
|
||||
**See also:** B265. The account in issue #40 has an active volume, so this fix does not run for
|
||||
it; its "missing key" comes from address keys that do not open.
|
||||
**Found:** 2026-10-15, issue #40: on a fresh 3.4.0 install the sign-in worked, but the daemon
|
||||
logged `mount failed: fetch My Files root: cryptography error: failed to decrypt: missing key`
|
||||
every 5 seconds. The app stayed on "Connecting…" because the daemon never got far enough to
|
||||
@@ -1606,6 +2078,7 @@ front-end is the stale one, and restarting the service would not help.
|
||||
### B205 — A photo upload the SDK refuses says "You're offline"
|
||||
|
||||
**Status:** Fixed, not verified live
|
||||
**See also:** B266, the same misclassification on the pin, unpin, search and node-load paths.
|
||||
**Found:** 2026-10-11, issue #38: uploading an image from the Photos page showed "You're offline"
|
||||
while other uploads went through. Each `upload_photo` failed about 5 µs after it started, with no
|
||||
request sent, and the journal gave no reason.
|
||||
@@ -1690,6 +2163,7 @@ taken as this session's, so the daemon keeps writing its later rotations.
|
||||
### B202 — A refused session shows as offline forever, and nothing says to sign in again
|
||||
|
||||
**Status:** Fixed, not verified live
|
||||
**See also:** B267. Other errors at a start from a warm cache still mount as offline.
|
||||
**Found:** 2026-10-09, issue #35: the tray and `pdfs diagnose` said "offline, 3 queued", and
|
||||
diagnose also said "keyring session: ok", while every call in the log failed with
|
||||
`InvalidRefreshToken (http 400)`. `pdfs sync now` and restarting the service changed nothing.
|
||||
@@ -2631,6 +3105,8 @@ fix).
|
||||
### B165 — A stop waits on a stuck thread until systemd kills the daemon
|
||||
|
||||
**Status:** Fixed (unverified).
|
||||
**See also:** B259. A pass still has no deadline while the daemon runs, and a stuck or dead
|
||||
sync engine is not reported.
|
||||
**Found:** 2026-10-04, by the account run on 2.10.0 (cleanup after on-demand pairing 2). The
|
||||
daemon was stopped at 15:53:04 and logged "control socket closing", but never "sync engine
|
||||
stopping" or "daemon stopping". systemd killed it 15 s later (`TimeoutStopSec`). The run's
|
||||
@@ -5877,6 +6353,13 @@ Only the three namespace ops were strictly required (the other four silence
|
||||
themselves once the kernel caches `ENOSYS`), but implementing all seven keeps the
|
||||
refusal explicit and documented at the call site rather than inherited.
|
||||
|
||||
`copy_file_range` has since been implemented: the daemon copies a range whose bytes are on this
|
||||
machine (an open write handle, a queued write, the cache) into the destination's scratch file,
|
||||
and answers `EOPNOTSUPP` for anything it would have to download. Unlike `ENOSYS`, that errno makes
|
||||
the kernel copy that one call through read and write and keeps asking the daemon next time.
|
||||
A warm `cp` of a 256 MiB file inside the mount went from about 100 MiB/s to about 450 MiB/s
|
||||
(sim, debug build; `a_copy_inside_the_mount_measures_its_speed`).
|
||||
|
||||
**Verified:** full live acceptance run after the fix — 19 pass / 1 skip, the
|
||||
capability diff identical to the pre-fix run, and **0** `Not Implemented` lines
|
||||
in the journal across the whole run (was 17, 10 of them `link`).
|
||||
|
||||
@@ -11,6 +11,80 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [3.6.0] — 2026-10-27
|
||||
|
||||
Schema: **44**, unchanged.
|
||||
|
||||
### Added
|
||||
- **`pdfs diagnose` names the key that keeps My Files closed.** When you are signed in and the
|
||||
service does not answer, a new **Keys** section asks Proton which account and address keys
|
||||
open, which account key each address key depends on, and which address key the My Files share
|
||||
is encrypted to. It shows key IDs only, no secrets (#40).
|
||||
- **A COPR repository for Fedora**, so `dnf upgrade` brings each new release:
|
||||
`sudo dnf copr enable narl-rs/proton-drive-linux`. Every release also ships the vendored crates
|
||||
as `proton-drive-linux-vendor-<version>.tar.xz`, which the COPR build uses offline (#42).
|
||||
- **The app can tell you about a new release.** Turn on **Check for updates** in Preferences, and
|
||||
the app asks GitHub once a day and shows a banner with a **Download** button when a newer
|
||||
version is out. It is off by default, and not offered for packages that a package manager
|
||||
updates (COPR, the AUR, Nix) (#42).
|
||||
- **Copy files and folders on Proton Drive**, without downloading or uploading them. In My files,
|
||||
right-click an item and choose **Make a Copy** to copy it next to itself, or **Copy To…** to pick
|
||||
another folder; **Copy To…** also works on a selection. From a terminal:
|
||||
`pdfs copy ~/ProtonDrive/report.pdf ~/ProtonDrive/Archive`. A folder is copied with everything
|
||||
in it, and a name already taken gets a " (1)" suffix. A file or folder with changes that have
|
||||
not synced yet is refused until they have.
|
||||
- **Change a public link without getting a new one.** In **Share…**, open **Link settings** under
|
||||
the link to switch between Viewer and Editor, set, change or remove the password, and change or
|
||||
remove the expiry. The address stays the same, so people who already have the link keep it.
|
||||
From a terminal: `pdfs public-link update PATH ID [--role] [--password | --no-password]
|
||||
[--expires EPOCH | --no-expiry]`. `pdfs members` now also shows when a link expires.
|
||||
- **Folder sizes.** **Details** shows how much a folder holds and how many items are in it, as
|
||||
Proton Drive counts it, including items in the trash. From a terminal: `pdfs du PATH`.
|
||||
|
||||
- **Sync a folder from scratch.** In a synced folder's menu on **Sync → Folders**, **Sync From
|
||||
Scratch…** forgets what was synced before and compares the folder with Proton Drive again. A
|
||||
file on one side only is copied to the other, and a file that differs keeps both versions.
|
||||
Nothing is deleted. From the terminal: `pdfs sync reset ID`.
|
||||
|
||||
- **`--json` for `pdfs members`, `pdfs du` and `pdfs public-link create` / `update`**, so scripts
|
||||
can read a link's id, role, password flag and expiry, and a folder's size and item count.
|
||||
|
||||
### Changed
|
||||
- **Copying a file inside the mount is faster** when the computer already has the file, for
|
||||
example with `cp` or a file manager: the copy is made on this computer, about four times
|
||||
faster for a 256 MiB file, instead of going through the mount page by page. A file that is
|
||||
only on Proton Drive is copied as before. The copy is then uploaded as usual.
|
||||
- **Reorganised documentation, now also on the
|
||||
[wiki](https://github.com/narrrl/proton-drive-linux/wiki).** A new
|
||||
[Getting started](https://github.com/narrrl/proton-drive-linux/wiki/Getting-Started) page and
|
||||
an [FAQ](https://github.com/narrrl/proton-drive-linux/wiki/FAQ). The user guide is split into
|
||||
pages for files, synced folders, Photos, sharing, computers, and the launcher and tray.
|
||||
Installation puts the COPR repository first for Fedora and explains updating and uninstalling
|
||||
for each kind of package. Each release publishes `docs/` to the wiki.
|
||||
- **Acceptance cases for the 3.6.0 features:** `copy_file_range` over several calls and at
|
||||
offsets, `pdfs copy` (including B256), `pdfs du`, `pdfs public-link update`, Sync From Scratch,
|
||||
and the switch of an emptied mirror folder to on-demand (B255). `docs/TESTING.md` lists what to
|
||||
check by hand in the app.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A synced folder whose files were all gone from this computer no longer gets stuck** (#46).
|
||||
The sync still refuses to delete everything on Proton Drive, but a switch to online only now
|
||||
goes ahead and shows what Proton Drive has, instead of waiting for good. This includes a
|
||||
switch asked for right after the files went, which was refused with "… was deleted here but
|
||||
not on Proton Drive yet". To download the files again instead, sync the folder from scratch.
|
||||
Restoring folders also logs why it skipped a folder.
|
||||
|
||||
- **The first sync of a folder shows how far its scan got** (#46). Before anything downloads,
|
||||
the first pass reads every item on Proton Drive, which takes many minutes for a large folder.
|
||||
The folder showed only "checking for changes…" all that time, and `pdfs sync list` only
|
||||
`scanning…`, which looked stuck. Both now count the items checked so far.
|
||||
|
||||
- **Saved public links show the name of what they point at**, in the app and in `pdfs bookmarks`,
|
||||
instead of "Shared link". For a link with a custom password, the address no longer has the custom
|
||||
password stuck to its end, so it matches the link the web shows and the page asks for the
|
||||
password.
|
||||
|
||||
## [3.5.0] — 2026-10-16
|
||||
|
||||
Schema: **44**. Queued changes get an index by kind and name, and each synced file remembers the
|
||||
|
||||
+19
-10
@@ -4,7 +4,8 @@
|
||||
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.
|
||||
`pdfs <command> --help` prints the full help of any command. This page groups them by task. For
|
||||
what the commands do in the app, see the [user guide](USER_GUIDE.md).
|
||||
|
||||
- [Conventions](#conventions)
|
||||
- [Account and service](#account-and-service)
|
||||
@@ -35,7 +36,9 @@ talks to the running service (`proton-drive.service`); `login`, `logout`, `mount
|
||||
|
||||
| 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 login [-u EMAIL] [--remember]` | 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. `--remember` keeps a two-password account unlocked across logins. |
|
||||
| `pdfs unlock [--remember]` | Enter the mailbox password of a two-password account, so the service can open your files. Needed once per login session, or once with `--remember`. |
|
||||
| `pdfs lock` | Forget the unlock held in memory; the account is locked again. |
|
||||
| `pdfs logout` | End the session on Proton and remove the stored session. |
|
||||
| `pdfs status` | Account, mount, queue and cache state. |
|
||||
| `pdfs quota` | Storage used across all Proton products. |
|
||||
@@ -43,7 +46,8 @@ talks to the running service (`proton-drive.service`); `login`, `logout`, `mount
|
||||
| `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.
|
||||
If Proton asks for a CAPTCHA, `pdfs login` prints the address of the page. Open it in your
|
||||
browser, complete it, then press Enter.
|
||||
|
||||
## Files
|
||||
|
||||
@@ -54,6 +58,8 @@ talks to the running service (`proton-drive.service`); `login`, `logout`, `mount
|
||||
| `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 copy PATH... NEW_PARENT` | Copy one or more files or folders into another folder in My files. The copy happens on Proton Drive, without downloading or uploading anything; a name already taken gets a " (1)" suffix. Changes that have not synced yet must sync first. |
|
||||
| `pdfs du PATH` | Show how much a folder holds on Proton Drive and how many items are in it. The size includes items in the trash. |
|
||||
| `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. |
|
||||
@@ -78,6 +84,7 @@ commands are for scripts and for cases where the mount is not available.
|
||||
| `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 reset ID` | Forget what a synced folder synced before and compare it with Proton Drive again. A file on one side only is copied to the other, and a file that differs keeps both versions. Nothing is deleted. |
|
||||
| `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. |
|
||||
@@ -86,7 +93,7 @@ commands are for scripts and for cases where the mount is not available.
|
||||
| `pdfs sync export ID DEST` | Save a copy of a queued upload's content. `DEST` may be a folder; an existing file is never overwritten. The upload stays queued. |
|
||||
| `pdfs sync discard ID [--yes]` | Drop a queued change and undo it here: a new file or folder goes, anything else goes back to what Proton Drive has. Content not exported first is lost. |
|
||||
| `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). |
|
||||
| `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](RECOVERY.md). |
|
||||
|
||||
## Conflicts
|
||||
|
||||
@@ -104,7 +111,7 @@ commands are for scripts and for cases where the mount is not available.
|
||||
| `pdfs devices rm UID` | Remove a computer and its backup from the account. |
|
||||
| `pdfs devices join UID [--yes]` | Sync the same folders as another computer. This machine syncs into that computer's backup alongside it, then offers its folders like `pdfs sync restore`. Both computers keep them in step. `--yes` skips the confirmation and accepts every proposed path. |
|
||||
| `pdfs devices adopt UID` | Make this machine continue another computer's backup, for example after a reinstall. `--clear` returns to matching by hostname. |
|
||||
| `pdfs devices migrate UID [--yes]` | Move another computer's folders into this computer's backup, then restore them here like `pdfs sync restore`. The other computer stays registered, without those folders. `--yes` skips the confirmation and accepts every proposed path. See [RECOVERY.md](RECOVERY.md). |
|
||||
| `pdfs devices migrate UID [--yes]` | Move another computer's folders into this computer's backup, then restore them here like `pdfs sync restore`. The other computer stays registered, without those folders. `--yes` skips the confirmation and accepts every proposed path. See [Recovery](RECOVERY.md). |
|
||||
|
||||
## Trash
|
||||
|
||||
@@ -129,10 +136,11 @@ commands are for scripts and for cases where the mount is not available.
|
||||
| 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 members PATH` | List members, pending invitations and the public link, with the link's id and expiry. |
|
||||
| `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 update PATH ID [--role] [--password TEXT \| --no-password] [--expires EPOCH \| --no-expiry]` | Change a public link's role, password or expiry. The URL stays the same, and an option you leave out is kept. |
|
||||
| `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. |
|
||||
@@ -171,19 +179,20 @@ commands are for scripts and for cases where the mount is not available.
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `pdfs diagnose` | Check the installation and print a report. Works without the service. Exits non-zero if a check fails. |
|
||||
| `pdfs diagnose` | Check the installation and print a report. Works without the service. When the service does not answer, also asks Proton which of your keys open. 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.
|
||||
[Troubleshooting](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.
|
||||
`devices list`, `locations`, `transfers`, `activity`, `trash`, `cache inspect`, `members` and `du`.
|
||||
`public-link create` and `public-link update` print the link they made or changed. Other commands
|
||||
that change something keep their human-readable output; check their exit code.
|
||||
|
||||
```bash
|
||||
pdfs --json status | jq -r '.mount.mountpoint'
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Computers
|
||||
|
||||
Every computer that backs up folders to your account has its own entry in Proton Drive. The
|
||||
**Computers** page lists this computer and every other one, including computers that run the
|
||||
official Windows or macOS app.
|
||||
|
||||
- [What you can do](#what-you-can-do)
|
||||
- [Syncing several computers](#syncing-several-computers)
|
||||
- [Replacing or reinstalling a computer](#replacing-or-reinstalling-a-computer)
|
||||
|
||||

|
||||
|
||||
## What you can do
|
||||
|
||||
**On this computer:**
|
||||
|
||||
- See its synced folders and their state, and add more with **Add Folder to Sync**. See
|
||||
[Synced folders](SYNCED_FOLDERS.md).
|
||||
- **Restore Folders…** brings back folders that are backed up under this computer but not synced
|
||||
here yet, for example after a reinstall.
|
||||
|
||||
**On another computer**, from its menu:
|
||||
|
||||
| Choose | When | What happens |
|
||||
|---|---|---|
|
||||
| **Browse Files** | You need a file from it | Its backup opens read-only, like My files |
|
||||
| **Restore to This Computer…** | You want a copy of its folders here | Its folders download here and stay part of its backup |
|
||||
| **Sync With This Computer…** | Both computers should share the same folders, such as a desktop and a laptop | Both keep the folders in step. See [below](#syncing-several-computers) |
|
||||
| **Continue This Backup Here…** | This computer replaces it, after a reinstall or with new hardware | This computer takes over its backup. See [Recovery](RECOVERY.md) |
|
||||
| **Move to This Computer…** | You switch for good, for example from Windows to Linux | Its folders move into this computer's backup and are offered for syncing here. The other computer stays in the list, without those folders |
|
||||
|
||||
`pdfs devices list` shows the same computers with their IDs, for the commands in the
|
||||
[command-line reference](CLI.md#computers).
|
||||
|
||||
## Syncing several computers
|
||||
|
||||
Two or more computers can sync the same folders through one backup in Proton Drive.
|
||||
|
||||
1. Sync the folders on the first computer as usual.
|
||||
2. On the second computer, open **Computers**, open the menu of the first computer and choose
|
||||
**Sync With This Computer…**. From a terminal: `pdfs devices join UID`, with the ID from
|
||||
`pdfs devices list`.
|
||||
3. Choose which of its folders to sync here, and where.
|
||||
|
||||
From then on, both computers sync into that backup:
|
||||
|
||||
- A folder you add later on either computer lands there too. A folder with the same name joins
|
||||
the one already there.
|
||||
- A change on one computer reaches the other within about two minutes.
|
||||
- When both computers change the same file within that time, each keeps the other's version under
|
||||
the original name, and its own as a conflict copy named after it. See
|
||||
[Conflicts](SYNCED_FOLDERS.md#conflicts).
|
||||
|
||||
Folders that were synced on this computer before it joined stay in its own backup.
|
||||
|
||||
## Replacing or reinstalling a computer
|
||||
|
||||
Your synced folders are on Proton Drive, so a new or reinstalled computer can pick up where the
|
||||
old one stopped. [Recovering on a new computer](RECOVERY.md) walks through it step by step, and
|
||||
explains what is lost with a machine and what is not.
|
||||
@@ -39,7 +39,7 @@ Inside `content/`:
|
||||
> not reached Proton Drive and exists nowhere else.
|
||||
|
||||
Everything in these directories except the keyring is **decrypted**. See
|
||||
[ARCHITECTURE.md §10](ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||
the [security model](SECURITY_MODEL.md#what-is-kept-on-this-computer).
|
||||
|
||||
## `config.json`
|
||||
|
||||
@@ -61,6 +61,7 @@ fix it.
|
||||
"proton_theme": true,
|
||||
"online_map": false,
|
||||
"tray_hidden": false,
|
||||
"update_check": false,
|
||||
"files_view": { "list": true, "sort": "modified", "descending": true, "folders_first": true },
|
||||
"window": { "width": 980, "height": 680, "maximized": false },
|
||||
"open_with": {
|
||||
@@ -87,6 +88,9 @@ fix it.
|
||||
| `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 |
|
||||
| `update_check` | bool | `false` | General → Updates | Ask GitHub once a day for a newer release and show it over the sidebar. Not offered where a package manager installs updates |
|
||||
| `update_last_checked` | Unix seconds | none | — | Written by the update check, so a restart does not ask again within a day |
|
||||
| `update_latest` | version | none | — | The latest release the update check found |
|
||||
| `files_view` | object | grid, by name | My files view menu | `list`, `sort` (`name`, `size`, `modified`), `descending`, `folders_first` |
|
||||
| `window` | object | 980 × 680 | Resizing the app window | `width`, `height` (the unmaximized size), `maximized` |
|
||||
| `open_with` | object | `xdg-open` | — | See [`open_with`](#open_with) |
|
||||
@@ -109,7 +113,7 @@ Gitignore-style rules applied to every synced folder, in addition to each folder
|
||||
```
|
||||
|
||||
Set it to `[]` to turn the defaults off. See
|
||||
[USER_GUIDE.md](USER_GUIDE.md#excluding-files-with-pdfsignore) for how ignoring behaves.
|
||||
[Synced folders](SYNCED_FOLDERS.md#excluding-files-with-pdfsignore) for how excluding behaves.
|
||||
|
||||
### `conflict_sweep`
|
||||
|
||||
@@ -198,7 +202,7 @@ Command-line flags win over the file: `--gtk`, `--dmenu`, `--fzf` and `--menu`.
|
||||
| `PDFS_STAND_IN_WIDGETS` | app | `1` uses the libadwaita 1.5 fallback widgets on newer systems (development) |
|
||||
|
||||
The tests have their own variables (`PDFS_ACCEPTANCE_*`, `PDFS_SIM_*`, `PDFS_MIGRATE_DB`),
|
||||
listed in [TESTING.md](TESTING.md).
|
||||
listed in [Testing](TESTING.md).
|
||||
|
||||
## The systemd service
|
||||
|
||||
|
||||
+32
-13
@@ -102,7 +102,7 @@ for l in $(cat po/LINGUAS); do msgfmt --check --statistics -o /dev/null po/$l.po
|
||||
| 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 diagnose` | Static checks: directories, FUSE, keyring, socket, database integrity. Works without the daemon. When the daemon does not answer, also traces which account and address keys open (`pdfs_core::keycheck`) |
|
||||
| `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 |
|
||||
@@ -141,31 +141,50 @@ file to `po/POTFILES.in`, run `po/update.sh`, and translate the new entries in e
|
||||
unit test, a simulation scenario or a `regression B<n>` acceptance case, and cite the entry in the
|
||||
code as `docs/BUGS.md B<n>`. Do not cite planning notes that are not in the repository.
|
||||
|
||||
**A user-visible change.** Update the matching page (user guide, CLI reference, configuration)
|
||||
and the `[Unreleased]` section of [CHANGELOG.md](CHANGELOG.md). `scripts/publish-wiki.sh` copies
|
||||
the six user pages (`INSTALL`, `USER_GUIDE`, `CLI`, `CONFIGURATION`, `TROUBLESHOOTING`, `RECOVERY`)
|
||||
to the GitHub wiki, so keep their file names.
|
||||
**A user-visible change.** Update the matching page (a user guide topic, the CLI reference,
|
||||
configuration) and the `[Unreleased]` section of [CHANGELOG.md](CHANGELOG.md). Link between pages
|
||||
with relative links such as `[Conflicts](SYNCED_FOLDERS.md#conflicts)`, and show screenshots from
|
||||
`images/` with `../images/<name>.png`.
|
||||
|
||||
**The wiki.** `docs/` is the source of the [GitHub wiki](https://github.com/narrrl/proton-drive-linux/wiki).
|
||||
`scripts/publish-wiki.sh` turns each page listed in its `SECTIONS` into a wiki page, rewrites the
|
||||
links, copies the images and writes the sidebar. `docs/README.md` becomes the wiki's Home.
|
||||
`BUGS.md` and `CHANGELOG.md` stay out of it; the wiki links to them on GitHub.
|
||||
|
||||
- `scripts/publish-wiki.sh --check` checks every link and heading anchor in `docs/`, `README.md`,
|
||||
`CONTRIBUTING.md` and `SECURITY.md`. It fails on a page in `docs/` that is not in `SECTIONS`
|
||||
or `EXCLUDED`. `.github/workflows/wiki.yml` runs it for every change to the documentation.
|
||||
- Each release publishes the wiki from the tag. Run the **Wiki** workflow from the Actions tab to
|
||||
publish `main` in between, for example after fixing a mistake.
|
||||
- `scripts/publish-wiki.sh [WORKDIR]` builds and commits the wiki in a local clone without
|
||||
pushing, so you can look at the result first. `--push` pushes it.
|
||||
|
||||
Never edit the wiki in the browser: the next publish replaces every page.
|
||||
|
||||
## Releasing
|
||||
|
||||
Releases are cut from `main` by pushing a `v*` tag. `.github/workflows/release.yml` then:
|
||||
|
||||
1. checks that the tag, the workspace version in `Cargo.toml` and `pkgver` in
|
||||
`packaging/PKGBUILD` agree;
|
||||
1. checks that the tag, the workspace version in `Cargo.toml`, `pkgver` in
|
||||
`packaging/PKGBUILD` and the default version in `packaging/proton-drive-linux.spec` agree;
|
||||
2. runs the quality gates;
|
||||
3. builds the release binaries and packages a `.tar.gz`, a `.deb` and a GPG-signed `.rpm`, and
|
||||
builds the Nix flake at the tag, which is what a flake input pinned to `vX.Y.Z` gets, and
|
||||
pushes it to the `proton-drive-linux` Cachix cache (secret `CACHIX_AUTH_TOKEN`);
|
||||
3. builds the release binaries and packages a `.tar.gz`, a `.deb`, a GPG-signed `.rpm` and a
|
||||
`.tar.xz` of the vendored crates, and builds the Nix flake at the tag, which is what a flake
|
||||
input pinned to `vX.Y.Z` gets, and pushes it to the `proton-drive-linux` Cachix cache (secret
|
||||
`CACHIX_AUTH_TOKEN`);
|
||||
4. publishes them as a GitHub release, with the version's section of `docs/CHANGELOG.md` as the
|
||||
notes;
|
||||
5. calls `.github/workflows/aur.yml`, which updates and pushes the AUR recipes and commits them
|
||||
back to `main`.
|
||||
back to `main`;
|
||||
6. calls `.github/workflows/copr.yml`, which builds the release in the
|
||||
[COPR repository](../packaging/copr/README.md) (secret `COPR_CONFIG`);
|
||||
7. calls `.github/workflows/wiki.yml`, which publishes `docs/` at the tag to the wiki.
|
||||
|
||||
To prepare a release:
|
||||
|
||||
```bash
|
||||
# 1. Bump the version in Cargo.toml, packaging/PKGBUILD, po/pdfs.pot and the pinned flake
|
||||
# URL in docs/INSTALL.md, then refresh the lock file.
|
||||
# 1. Bump the version in Cargo.toml, packaging/PKGBUILD, packaging/proton-drive-linux.spec,
|
||||
# po/pdfs.pot and the pinned flake URL in docs/INSTALL.md, 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.
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
# FAQ
|
||||
|
||||
Short answers to common questions, with links to the details.
|
||||
|
||||
- [Is this an official Proton app?](#is-this-an-official-proton-app)
|
||||
- [If I delete a file in `~/ProtonDrive`, is it deleted on Proton Drive?](#if-i-delete-a-file-in-protondrive-is-it-deleted-on-proton-drive)
|
||||
- [Online only, available offline or synced folder: which do I want?](#online-only-available-offline-or-synced-folder-which-do-i-want)
|
||||
- [Does it work offline?](#does-it-work-offline)
|
||||
- [How do I get updates?](#how-do-i-get-updates)
|
||||
- [Why is there no tray icon?](#why-is-there-no-tray-icon)
|
||||
- [What is stored unencrypted on my computer?](#what-is-stored-unencrypted-on-my-computer)
|
||||
- [Does it send telemetry?](#does-it-send-telemetry)
|
||||
- [Can I use it on several computers?](#can-i-use-it-on-several-computers)
|
||||
- [Can I continue a backup from Windows or macOS?](#can-i-continue-a-backup-from-windows-or-macos)
|
||||
- [Can I run it on a server without a desktop?](#can-i-run-it-on-a-server-without-a-desktop)
|
||||
- [Which architectures and languages are supported?](#which-architectures-and-languages-are-supported)
|
||||
- [How do I report a bug?](#how-do-i-report-a-bug)
|
||||
|
||||
## Is this an official Proton app?
|
||||
|
||||
No. It is an independent, open-source client, not affiliated with or endorsed by Proton AG. It
|
||||
uses a Rust port of Proton's own Drive SDK, and everything stays end-to-end encrypted. It has had
|
||||
no independent security audit. See the [security model](SECURITY_MODEL.md).
|
||||
|
||||
## If I delete a file in `~/ProtonDrive`, is it deleted on Proton Drive?
|
||||
|
||||
Yes. `~/ProtonDrive` is your Proton Drive, not a copy. A deleted file moves to the Proton Drive
|
||||
Trash, and you can restore it from **Trash** in the app or the web. See [Trash](FILES.md#trash).
|
||||
|
||||
Stopping the service is not the same: once the service stops, `~/ProtonDrive` is just an empty
|
||||
folder, and your files stay on Proton Drive.
|
||||
|
||||
## Online only, available offline or synced folder: which do I want?
|
||||
|
||||
- **Online only** for most of your Drive. Files take no space until you open them.
|
||||
- **Available offline** for files in `~/ProtonDrive` you need without a connection.
|
||||
- **Synced folder** for a folder that lives on this computer, such as `~/Documents`, and should be
|
||||
backed up.
|
||||
|
||||
[Getting started](GETTING_STARTED.md#3-choose-how-files-are-kept) compares them side by side.
|
||||
|
||||
## Does it work offline?
|
||||
|
||||
Yes, for files that are on this computer: available-offline files, synced folders, and recently
|
||||
opened files still in the cache. You can also create, rename, move and delete files offline. The
|
||||
changes are queued and sent when the connection is back. See
|
||||
[Changes waiting for Proton Drive](SYNCED_FOLDERS.md#changes-waiting-for-proton-drive).
|
||||
|
||||
## How do I get updates?
|
||||
|
||||
Through your package manager. The Fedora COPR repository, the AUR and the Nix flake get every
|
||||
release, so `dnf upgrade`, your AUR helper or a flake update brings it. The `.deb` has to be
|
||||
downloaded again for each release. To be told about new releases, turn on **Check for updates**
|
||||
in **Preferences**. The app never updates itself. See [Installation → Updating](INSTALL.md#updating).
|
||||
|
||||
## Why is there no tray icon?
|
||||
|
||||
GNOME needs the
|
||||
[AppIndicator extension](https://extensions.gnome.org/extension/615/appindicator-support/) to show
|
||||
tray icons. If you hid the icon from its own menu, turn it back on under
|
||||
**Preferences → General → Appearance**.
|
||||
|
||||
## What is stored unencrypted on my computer?
|
||||
|
||||
To show your files, the client decrypts them on this computer. Recently opened files, files kept
|
||||
offline, changes waiting to upload, and the names of your files and folders are stored without
|
||||
extra encryption. Use full-disk or home-folder encryption. The
|
||||
[security model](SECURITY_MODEL.md#what-is-kept-on-this-computer) has the full list.
|
||||
|
||||
## Does it send telemetry?
|
||||
|
||||
No. The client connects only to Proton, and, if you turn them on, to OpenFreeMap for the street map
|
||||
in Photos and to GitHub to check for updates. See
|
||||
[Network connections](SECURITY_MODEL.md#network-connections).
|
||||
|
||||
## Can I use it on several computers?
|
||||
|
||||
Yes. Each computer has its own backup in Proton Drive, and two or more computers can also sync the
|
||||
same folders. See [Syncing several computers](COMPUTERS.md#syncing-several-computers).
|
||||
|
||||
## Can I continue a backup from Windows or macOS?
|
||||
|
||||
Yes. On the **Computers** page, choose **Move to This Computer…** on the old computer. Its folders
|
||||
move into this computer's backup on Proton Drive, without uploading anything again. See
|
||||
[Moving another computer's backup here](RECOVERY.md#moving-another-computers-backup-here).
|
||||
|
||||
## Can I run it on a server without a desktop?
|
||||
|
||||
Yes, with `pdfs daemon` or `pdfs mount`. It still needs a Secret Service provider such as GNOME
|
||||
Keyring or KeePassXC to hold the session. See
|
||||
[Headless and server use](INSTALL.md#headless-and-server-use).
|
||||
|
||||
## Which architectures and languages are supported?
|
||||
|
||||
Release packages are for x86_64. Other architectures can [build from source](INSTALL.md#build-from-source).
|
||||
|
||||
The app, tray and launcher are available in English, German, Spanish, French, Italian, Dutch,
|
||||
Polish and Brazilian Portuguese. [Translating](TRANSLATING.md) explains how to add a language.
|
||||
|
||||
## How do I report a bug?
|
||||
|
||||
Run `pdfs diagnose`, then [open an issue](https://github.com/narrrl/proton-drive-linux/issues/new/choose)
|
||||
with what [Reporting a problem](TROUBLESHOOTING.md#reporting-a-problem) lists. Report security
|
||||
problems privately, as described in [SECURITY.md](../SECURITY.md).
|
||||
@@ -0,0 +1,95 @@
|
||||
# Files and offline access
|
||||
|
||||
Your Proton Drive appears on this computer as a folder, `~/ProtonDrive`. Every file and folder in
|
||||
it is listed straight away, but a file only downloads when a program opens it. This page covers
|
||||
working in that folder, keeping files on this computer, the Trash, and earlier versions of a
|
||||
file.
|
||||
|
||||
- [Your Drive as a folder](#your-drive-as-a-folder)
|
||||
- [Keep files available offline](#keep-files-available-offline)
|
||||
- [Browse in the app](#browse-in-the-app)
|
||||
- [Trash](#trash)
|
||||
- [Earlier versions of a file](#earlier-versions-of-a-file)
|
||||
- [Choose which program opens a file](#choose-which-program-opens-a-file)
|
||||
|
||||
## Your Drive as a folder
|
||||
|
||||
`~/ProtonDrive` is your Proton Drive, called **My files** in the app. It is not a copy. A file you
|
||||
change there changes on Proton Drive, and a file you delete there goes to the Proton Drive
|
||||
Trash. Every program can use it, from your file manager to a terminal.
|
||||
|
||||
- **Only what you read is downloaded.** A video player that seeks, or a tool that reads only the
|
||||
start of a file, downloads only those parts. Videos and music start playing before the whole
|
||||
file is down.
|
||||
- **Changes are instant, online or offline.** Creating, renaming, moving and deleting return at
|
||||
once. When a program saves and closes a file, the new content is stored on this computer and
|
||||
sent to Proton Drive in the background. Changes survive a lost connection, a crash or a reboot.
|
||||
See [Changes waiting for Proton Drive](SYNCED_FOLDERS.md#changes-waiting-for-proton-drive).
|
||||
- **Recently opened files are cached** up to a size limit (5 GiB by default, set in
|
||||
**Preferences → Storage**). The oldest are removed first to make room.
|
||||
- **Shared with me** is a folder inside `~/ProtonDrive` that holds what other people share with
|
||||
you. Shares where you are a viewer are read-only.
|
||||
|
||||
Proton Drive cannot store symbolic links, hard links, device files or FIFOs. A program that tries
|
||||
to create one gets an error at once. For a project that needs them, use a
|
||||
[synced folder](SYNCED_FOLDERS.md) and exclude those paths.
|
||||
|
||||
To move the folder somewhere else, change **Preferences → General → Location**.
|
||||
|
||||
## Keep files available offline
|
||||
|
||||
Files you mark **Available offline** are downloaded completely and stay on this computer, so
|
||||
they open without a connection.
|
||||
|
||||
- **In the app:** select a file or folder in **My files** and turn on **Available offline**.
|
||||
- **From a terminal:** `pdfs pin ~/ProtonDrive/Documents/Taxes`
|
||||
|
||||
For a folder, every file in it and in its subfolders downloads in the background. The tray and
|
||||
the app show the progress. **Preferences → Storage → Available offline** and `pdfs pins` list
|
||||
what is kept.
|
||||
|
||||
Turn it off again with the same switch, or `pdfs unpin PATH`. This frees the space. The files stay
|
||||
on Proton Drive. A file inside a folder that is kept offline stays with its folder: turn the
|
||||
folder off instead.
|
||||
|
||||
## Browse in the app
|
||||
|
||||

|
||||
|
||||
The **My files** page shows the same files without going through the folder. It has a grid and
|
||||
a list view, sorting, thumbnails (including camera RAW previews), multi-select, and drag and drop
|
||||
to upload. **Details** (Alt+Enter) shows the size, dates, sharing and version history of the
|
||||
selected item. For a folder, the size and the number of items in it come from Proton Drive and
|
||||
include items in the trash; `pdfs du PATH` prints the same.
|
||||
|
||||
Right-click an item for more. **Make a Copy** copies it into the same folder, and **Copy To…**
|
||||
copies it, or a whole selection, into another folder in My files. The copy is made on Proton
|
||||
Drive, so nothing is downloaded or uploaded, and a name already taken gets a " (1)" suffix. An
|
||||
item with changes that have not reached Proton Drive yet can be copied once they have. From a
|
||||
terminal: `pdfs copy PATH... NEW_PARENT`.
|
||||
|
||||
## Trash
|
||||
|
||||
Deleted files go to the Trash, both from `~/ProtonDrive` and from the app. The **Trash** page
|
||||
lists them with the date they were deleted.
|
||||
|
||||
- **Restore** puts an item back where it was, together with any trashed folders above it.
|
||||
- **Delete Forever** and **Empty Trash** are permanent.
|
||||
|
||||
From a terminal: `pdfs trash` lists the Trash with an ID for each item, and `pdfs restore`,
|
||||
`pdfs delete-forever` and `pdfs empty-trash` take those IDs.
|
||||
|
||||
## Earlier versions of a file
|
||||
|
||||
Proton Drive keeps earlier versions of every file. Open them with **Details → Versions…** in the
|
||||
app, or `pdfs versions list PATH`. You can:
|
||||
|
||||
- **Restore** a version. This happens on Proton Drive, so nothing is uploaded again.
|
||||
- **Save** a version to a separate file, without touching the current one.
|
||||
- **Delete** an old version permanently. The current version cannot be deleted.
|
||||
|
||||
## Choose which program opens a file
|
||||
|
||||
The app and the search launcher open files with your desktop's default program (`xdg-open`). To
|
||||
open some file types differently, for example text files in Neovim inside a terminal, add
|
||||
`open_with` rules to `config.json`. See [Configuration → `open_with`](CONFIGURATION.md#open_with).
|
||||
@@ -0,0 +1,72 @@
|
||||
# Getting started
|
||||
|
||||
From installing to your first synced folder in a few minutes.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Proton Drive for Linux is an unofficial client, built by the community and not by Proton. Try it
|
||||
> first with files you also have somewhere else.
|
||||
|
||||
## 1. Install
|
||||
|
||||
| Distribution | Command |
|
||||
|---|---|
|
||||
| Fedora 44 or newer | `sudo dnf copr enable narl-rs/proton-drive-linux`<br>`sudo dnf install proton-drive-linux` |
|
||||
| Arch Linux | `paru -S proton-drive-for-linux` (or any other AUR helper) |
|
||||
| Debian, Ubuntu 24.04 or newer | Download the `.deb` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest), then `sudo apt install ./proton-drive-linux_*_amd64.deb` |
|
||||
| NixOS | See [Installation → NixOS](INSTALL.md#nixos-flake) |
|
||||
|
||||
On GNOME, also install the
|
||||
[AppIndicator extension](https://extensions.gnome.org/extension/615/appindicator-support/) to see
|
||||
the tray icon. [Installation](INSTALL.md) has every option, building from source, and the optional
|
||||
extras.
|
||||
|
||||
## 2. Sign in
|
||||
|
||||

|
||||
|
||||
1. Open **Proton Drive** from your applications menu.
|
||||
2. Sign in with your Proton email and password, and the two-factor code if your account has one.
|
||||
If Proton asks you to prove you are human (common on a VPN), solve the challenge in the window
|
||||
that opens.
|
||||
3. Wait a moment. Your Proton Drive appears as the folder `~/ProtonDrive`.
|
||||
|
||||
The app starts the Proton Drive service for you, and the service starts by itself whenever you
|
||||
log in. Your session is kept in the system keyring (GNOME Keyring, KWallet or KeePassXC), so you
|
||||
sign in only once.
|
||||
|
||||
## 3. Choose how files are kept
|
||||
|
||||
There are three ways to have your files on this computer. You can mix them freely.
|
||||
|
||||
| | Takes disk space | Works offline | Use it for |
|
||||
|---|---|---|---|
|
||||
| **Online only** (the default in `~/ProtonDrive`) | Only for files you opened recently | Only files still in the cache | Most of your Drive: archives, media, anything large |
|
||||
| **Available offline** | Yes | Yes | Files and folders you need on the road |
|
||||
| **Synced folder** | Yes, a full copy | Yes | Folders that live on this computer, such as `~/Documents`, backed up to Proton Drive |
|
||||
|
||||
- **Online only:** open `~/ProtonDrive` in your file manager. Files download when you open them.
|
||||
- **Available offline:** select a file or folder in the app and turn on **Available offline**.
|
||||
[More about offline files](FILES.md#keep-files-available-offline).
|
||||
- **Synced folder:** in the app, open **Computers** and choose **Add Folder to Sync**.
|
||||
[More about synced folders](SYNCED_FOLDERS.md).
|
||||
|
||||
## 4. Know where to look
|
||||
|
||||
- **Tray icon:** shows whether everything is up to date, and pauses or resumes sync.
|
||||
- **Sync → Overview** in the app: what is uploading, what is waiting, and anything that needs you.
|
||||
- **Search launcher:** bind `pdfs-prompt` to a keyboard shortcut to find any file in Proton Drive
|
||||
or your home folder. See [Search launcher and tray](LAUNCHER_AND_TRAY.md).
|
||||
|
||||
> [!WARNING]
|
||||
> `~/ProtonDrive` is your Proton Drive, not a copy. Deleting a file there moves it to the Proton
|
||||
> Drive Trash on every device. You can restore it from **Trash** in the app.
|
||||
|
||||
## Next steps
|
||||
|
||||
- [User guide](USER_GUIDE.md): everything the app, tray and launcher do.
|
||||
- [FAQ](FAQ.md): short answers to common questions.
|
||||
- [Troubleshooting](TROUBLESHOOTING.md): when something does not work.
|
||||
- [Recovering on a new computer](RECOVERY.md): read this before you rely on the client for
|
||||
backups.
|
||||
- Prefer a terminal? `pdfs status` shows the state of everything, and the
|
||||
[command-line reference](CLI.md) lists every command.
|
||||
+226
-162
@@ -1,70 +1,53 @@
|
||||
# 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.
|
||||
Install Proton Drive for Linux from your distribution's package, sign in, and keep it up to date.
|
||||
This page also covers building from source, running without a desktop, and removing the client
|
||||
again.
|
||||
|
||||
- [Requirements](#requirements)
|
||||
- [Install a package](#install-a-package)
|
||||
- [Build from source](#build-from-source)
|
||||
- [Optional extras](#optional-extras)
|
||||
- [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 |
|
||||
- [Headless and server use](#headless-and-server-use)
|
||||
- [Build from source](#build-from-source)
|
||||
- [What the packages install](#what-the-packages-install)
|
||||
|
||||
## 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:
|
||||
| Distribution | Package | Updates |
|
||||
|---|---|---|
|
||||
| [Fedora 44 or newer](#fedora-44-or-newer) | COPR repository `narl-rs/proton-drive-linux` | With `dnf upgrade` |
|
||||
| [Arch Linux](#arch-linux-aur) | `proton-drive-for-linux` in the AUR | With your AUR helper |
|
||||
| [Debian, Ubuntu 24.04 or newer](#debian-and-ubuntu-2404-or-newer) | `.deb` from the release page | Download each release |
|
||||
| [NixOS](#nixos-flake) | The flake in this repository | With `nix flake update` |
|
||||
| [Anything else](#other-distributions) | `.tar.gz` from the release page, or [build from source](#build-from-source) | By hand |
|
||||
|
||||
| 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
|
||||
```
|
||||
Release packages are for x86_64. You also need a Secret Service provider, such as GNOME
|
||||
Keyring, KWallet or KeePassXC, to keep your session. Most desktops have one already.
|
||||
|
||||
### Fedora (44 or newer)
|
||||
|
||||
Enable the [COPR repository](https://copr.fedorainfracloud.org/coprs/narl-rs/proton-drive-linux/)
|
||||
and install the package:
|
||||
|
||||
```bash
|
||||
sudo dnf copr enable narl-rs/proton-drive-linux
|
||||
sudo dnf install proton-drive-linux
|
||||
```
|
||||
|
||||
COPR builds every release for Fedora 44, 45 and Rawhide, so `sudo dnf upgrade` keeps the client
|
||||
up to date along with the rest of your system.
|
||||
|
||||
You can also download the signed `.rpm` from the
|
||||
[latest release](https://github.com/narrrl/proton-drive-linux/releases/latest). It installs the
|
||||
same files, but you have to download each new version yourself:
|
||||
|
||||
```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).
|
||||
To build an RPM from your own checkout, see [Build an RPM locally](#build-an-rpm-locally).
|
||||
|
||||
### Arch Linux (AUR)
|
||||
|
||||
@@ -72,21 +55,33 @@ Three packages cover the usual choices. They conflict with each other, so instal
|
||||
|
||||
| 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 |
|
||||
| [`proton-drive-for-linux`](https://aur.archlinux.org/packages/proton-drive-for-linux) | Latest release, built from source |
|
||||
| [`proton-drive-for-linux-bin`](https://aur.archlinux.org/packages/proton-drive-for-linux-bin) | Latest release, prebuilt binaries |
|
||||
| [`proton-drive-for-linux-git`](https://aur.archlinux.org/packages/proton-drive-for-linux-git) | The newest code on `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).
|
||||
The recipes are in [`packaging/aur/`](../packaging/aur/README.md).
|
||||
|
||||
### Debian and Ubuntu (24.04 or newer)
|
||||
|
||||
Download the `.deb` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest)
|
||||
and install it:
|
||||
|
||||
```bash
|
||||
sudo apt install ./proton-drive-linux_*_amd64.deb
|
||||
```
|
||||
|
||||
There is no APT repository yet, so repeat this for each new release. Turn on **Check for
|
||||
updates** in **Preferences** to be told when one is out.
|
||||
|
||||
### NixOS (flake)
|
||||
|
||||
The repository is a flake. Add it as an input and enable the module, which installs the package,
|
||||
links the user service into every graphical session and turns on the `fusermount3` wrapper and
|
||||
GNOME Keyring for you:
|
||||
The repository is a flake. Add it as an input and enable the module. The module installs the
|
||||
package, starts the user service in every graphical session, and turns on the `fusermount3`
|
||||
wrapper and GNOME Keyring for you:
|
||||
|
||||
```nix
|
||||
{
|
||||
@@ -107,7 +102,7 @@ That URL follows `main`. To stay on releases, pin the input to a release tag ins
|
||||
the tag to update:
|
||||
|
||||
```nix
|
||||
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.3.0";
|
||||
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.6.0";
|
||||
```
|
||||
|
||||
Each release tag is built with Nix before the release is published.
|
||||
@@ -128,29 +123,176 @@ nix.settings = {
|
||||
```
|
||||
|
||||
The cache holds the build against the `nixpkgs` this flake locks. Setting
|
||||
`inputs.proton-drive-linux.inputs.nixpkgs.follows = "nixpkgs"` builds against yours instead, which
|
||||
the cache does not have, so Nix compiles it. `nix run` and `nix shell` ask whether to use the
|
||||
cache the first time; answer yes, or run `cachix use proton-drive-linux`.
|
||||
`inputs.proton-drive-linux.inputs.nixpkgs.follows = "nixpkgs"` builds against yours instead,
|
||||
which the cache does not have, so Nix compiles it. `nix run` and `nix shell` ask whether to use
|
||||
the cache the first time; answer yes, or run `cachix use proton-drive-linux`.
|
||||
|
||||
To try it without changing your system, run the app straight from the flake:
|
||||
To try it without changing your system, run it straight from the flake:
|
||||
|
||||
```bash
|
||||
nix run github:narrrl/proton-drive-linux#proton-drive-linux -- --help # the CLI
|
||||
nix shell github:narrrl/proton-drive-linux -c pdfs-app # the desktop app
|
||||
```
|
||||
|
||||
The mount itself needs the setuid `fusermount3` from NixOS (`programs.fuse.enable`, on by default
|
||||
with the module) and a Secret Service such as GNOME Keyring or KWallet. To leave out ffmpeg and
|
||||
with it video thumbnails, override the package with `withFfmpeg = false`. The recipe lives in
|
||||
The mount needs the setuid `fusermount3` from NixOS (`programs.fuse.enable`, on by default with
|
||||
the module) and a Secret Service such as GNOME Keyring or KWallet. To leave out ffmpeg, and with
|
||||
it video thumbnails, override the package with `withFfmpeg = false`. The recipe is in
|
||||
[`packaging/nix/`](../packaging/nix/).
|
||||
|
||||
### 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).
|
||||
Unpack the `.tar.gz` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest)
|
||||
into a directory on your `PATH`, then install the service as shown in
|
||||
[Using a binary outside `/usr/bin`](#using-a-binary-outside-usrbin). The binaries need GTK 4.12,
|
||||
libadwaita 1.5, WebKitGTK 6.0 and FUSE 3; see [Requirements](#requirements).
|
||||
|
||||
## Optional extras
|
||||
|
||||
The packages pull in what the client needs. These add features if you install them:
|
||||
|
||||
| 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` and a terminal (foot, kitty, alacritty, …) | `pdfs-prompt --fzf`, the search-as-you-type launcher |
|
||||
| [AppIndicator extension](https://extensions.gnome.org/extension/615/appindicator-support/) (GNOME only) | The tray icon. KDE Plasma and most other desktops show it without help |
|
||||
|
||||
## First run
|
||||
|
||||
### With the desktop app
|
||||
|
||||
1. Open **Proton Drive** from your applications 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 belongs to your desktop session. It starts after you log in and stops when you log
|
||||
out. To put the folder somewhere else, change **Preferences → General → Location**.
|
||||
|
||||
[Getting started](GETTING_STARTED.md) explains what to do next.
|
||||
|
||||
### With the command line
|
||||
|
||||
`pdfs login` stores the session but does not enable the service, so a setup that runs the service
|
||||
another way stays untouched. Enable it yourself:
|
||||
|
||||
```bash
|
||||
pdfs login # email, password, 2FA code
|
||||
systemctl --user enable --now proton-drive.service
|
||||
pdfs status # account, mount and queue state
|
||||
```
|
||||
|
||||
`pdfs login` prints the address of the CAPTCHA page if Proton asks for one: open it in your
|
||||
browser, solve it, then press Enter.
|
||||
|
||||
## Updating
|
||||
|
||||
| Installed from | Update with |
|
||||
|---|---|
|
||||
| COPR (Fedora) | `sudo dnf upgrade` |
|
||||
| AUR | `paru -Syu`, or your AUR helper's equivalent |
|
||||
| Nix flake | `nix flake update proton-drive-linux`, or change the pinned tag, then rebuild |
|
||||
| `.deb`, release `.rpm`, `.tar.gz` | Download the new release and install it over the old one |
|
||||
|
||||
The service switches to the new version the next time it starts. To switch at once:
|
||||
|
||||
```bash
|
||||
systemctl --user restart proton-drive.service
|
||||
```
|
||||
|
||||
With the `.deb`, the release `.rpm` or the `.tar.gz`, nothing tells you about a new release.
|
||||
Turn on **Preferences → General → Check for updates**: the app asks GitHub once a day and shows a
|
||||
banner over the sidebar when a newer version is out. The app never installs updates itself.
|
||||
Packages that a package manager updates do not offer the setting. A package marks itself that way
|
||||
by installing the empty file `share/proton-drive-linux/managed-updates` under the prefix of its
|
||||
`bin/` directory, for example `/usr/share/proton-drive-linux/managed-updates`.
|
||||
|
||||
Coming from 2.8.0 or older, re-enable the service once. Those releases started it before the
|
||||
desktop session was ready, and 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 upgrades run by themselves and only move forward. A newer release's database does not
|
||||
open in an older release, so going back to an older version 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 anything. `pdfs sync queue` must be
|
||||
> empty. Changes that have not reached Proton Drive exist only on this computer.
|
||||
|
||||
1. Stop the service and sign out:
|
||||
|
||||
```bash
|
||||
pdfs sync queue # must list nothing
|
||||
systemctl --user disable --now proton-drive.service
|
||||
pdfs logout # removes the session from the keyring
|
||||
```
|
||||
|
||||
2. Remove the package:
|
||||
|
||||
| Installed from | Remove with |
|
||||
|---|---|
|
||||
| COPR (Fedora) | `sudo dnf remove proton-drive-linux`, then `sudo dnf copr remove narl-rs/proton-drive-linux` |
|
||||
| Release `.rpm` | `sudo dnf remove proton-drive-linux` |
|
||||
| `.deb` | `sudo apt remove proton-drive-linux` |
|
||||
| AUR | `sudo pacman -R proton-drive-for-linux` (or the `-bin` or `-git` package) |
|
||||
| NixOS | Remove the module and the flake input, then rebuild |
|
||||
|
||||
Removing the package also stops the service of every logged-in user, in case step 1 was
|
||||
skipped.
|
||||
|
||||
3. If you want no trace left, remove the local data:
|
||||
|
||||
```bash
|
||||
rm -rf ~/.config/proton-drive-linux ~/.local/state/proton-drive-linux ~/.cache/proton-drive-linux
|
||||
```
|
||||
|
||||
4. Sign the device out in your Proton account settings. If you no longer want this computer's
|
||||
backups, remove it from **Computers** in the Proton Drive web app.
|
||||
|
||||
> [!CAUTION]
|
||||
> Do not delete `~/ProtonDrive` while it is mounted. It is your Proton Drive, not a copy: every
|
||||
> file deleted there is moved to the Proton Drive trash. Once the service is stopped, the folder
|
||||
> is empty, and removing it is safe.
|
||||
|
||||
## Headless and server use
|
||||
|
||||
The packaged service starts with a desktop session. On a machine without one, run the service
|
||||
directly or from your own systemd 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 for the session. On a server, run
|
||||
`gnome-keyring-daemon --unlock` or KeePassXC's Secret Service integration inside the user's D-Bus
|
||||
session.
|
||||
|
||||
## Build from source
|
||||
|
||||
### Requirements
|
||||
|
||||
| | Minimum |
|
||||
|---|---|
|
||||
| Architecture | Any that Rust and GTK support. Release packages are x86_64 only |
|
||||
| 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 | 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.
|
||||
|
||||
### Build dependencies
|
||||
|
||||
**Debian / Ubuntu 24.04+**
|
||||
@@ -173,8 +315,8 @@ sudo dnf install -y cargo rust gettext pkgconf-pkg-config fuse3-devel gtk4-devel
|
||||
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.
|
||||
If your distribution ships a Rust older than 1.96, install a newer one with
|
||||
[rustup](https://rustup.rs).
|
||||
|
||||
### Compile
|
||||
|
||||
@@ -185,7 +327,7 @@ 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`.
|
||||
The four programs are in `target/release/`: `pdfs`, `pdfs-app`, `pdfs-tray` and `pdfs-prompt`.
|
||||
|
||||
### Build a package from the working tree
|
||||
|
||||
@@ -211,7 +353,7 @@ 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
|
||||
The service unit starts `/usr/bin/pdfs daemon`. If you installed the programs somewhere else, for
|
||||
example `~/.local/bin`, install the unit for your user and point it at your binary:
|
||||
|
||||
```bash
|
||||
@@ -223,98 +365,20 @@ 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/`.
|
||||
`~/.config/autostart/`. For the entry in your applications menu, 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
|
||||
## What the packages install
|
||||
|
||||
### With the desktop app
|
||||
Every package installs the same files:
|
||||
|
||||
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`). Removing the package also stops
|
||||
the service of every logged-in user, in case a step above was skipped.
|
||||
|
||||
> [!CAUTION]
|
||||
> Do not delete `~/ProtonDrive` while it is mounted. It is your Proton Drive, not a copy: every
|
||||
> file deleted there is moved to the Proton Drive trash. Once the service is stopped the folder is
|
||||
> an empty mount point, and removing it is safe.
|
||||
|
||||
If you want no trace left, remove the local data as well:
|
||||
|
||||
```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.
|
||||
| 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 |
|
||||
@@ -0,0 +1,86 @@
|
||||
# Search launcher and tray
|
||||
|
||||
The search launcher finds files in Proton Drive and in your home folder from a keyboard shortcut.
|
||||
The tray icon shows whether everything is up to date and gives quick access to the app.
|
||||
|
||||
- [Search launcher](#search-launcher)
|
||||
- [Using your own launcher](#using-your-own-launcher)
|
||||
- [Search as you type with fzf](#search-as-you-type-with-fzf)
|
||||
- [Tray](#tray)
|
||||
|
||||
## Search launcher
|
||||
|
||||

|
||||
|
||||
`pdfs-prompt` searches Proton Drive and your home folder together, in one list with the best
|
||||
matches first. It copes with prefixes, abbreviations, typos and swapped letters, and also matches
|
||||
the names of parent folders.
|
||||
|
||||
To set it up, bind `pdfs-prompt` to a keyboard shortcut in your desktop's settings.
|
||||
|
||||
The window stays loaded after the first use, so later shortcuts open it instantly. Folders, music
|
||||
and videos open straight from `~/ProtonDrive`, so players can start before the whole file is down.
|
||||
Other files from Proton Drive download first, then open.
|
||||
|
||||
To make even the first shortcut instant, start the launcher hidden when you log in.
|
||||
`pdfs-prompt --preload` loads the window without showing it, and does nothing if the launcher is
|
||||
already running. For example, in Hyprland:
|
||||
|
||||
```ini
|
||||
exec-once = pdfs-prompt --preload
|
||||
bind = SUPER, space, exec, pdfs-prompt
|
||||
```
|
||||
|
||||
Which program opens a file can be changed per file type. See
|
||||
[Choose which program opens a file](FILES.md#choose-which-program-opens-a-file).
|
||||
|
||||
## Using your own launcher
|
||||
|
||||
`pdfs-prompt --dmenu` shows the results in fuzzel, rofi, wofi, tofi, bemenu or dmenu, whichever is
|
||||
installed:
|
||||
|
||||
```bash
|
||||
pdfs-prompt --dmenu
|
||||
pdfs-prompt --dmenu --menu 'fuzzel --dmenu --width 60'
|
||||
pdfs-prompt --dmenu --query invoice
|
||||
```
|
||||
|
||||
These launchers filter a fixed list and cannot ask for new results while you type, so the search
|
||||
takes two steps:
|
||||
|
||||
1. The first list, `Search Drive ›`, shows your available-offline files. Type a search and press
|
||||
Enter.
|
||||
2. The second list, `Drive: invoice ›`, shows the results. Enter opens one. Typing something else
|
||||
searches again.
|
||||
|
||||
## Search as you type with fzf
|
||||
|
||||

|
||||
|
||||
`pdfs-prompt --fzf` searches again on every key press, like the built-in window. It needs `fzf`.
|
||||
Started from a keyboard shortcut, it opens a terminal by itself (foot, ghostty, kitty, alacritty,
|
||||
wezterm or xterm) with the app ID `pdfs-prompt`, so one window rule covers all of them. For
|
||||
example, in Hyprland:
|
||||
|
||||
```ini
|
||||
windowrulev2 = float, class:^(pdfs-prompt)$
|
||||
windowrulev2 = size 900 500, class:^(pdfs-prompt)$
|
||||
windowrulev2 = center, class:^(pdfs-prompt)$
|
||||
bind = SUPER, space, exec, pdfs-prompt --fzf
|
||||
```
|
||||
|
||||
To make `--dmenu` or `--fzf` the default without changing your shortcut, set `prompt.mode` in
|
||||
`config.json` (see [Configuration → `prompt`](CONFIGURATION.md#prompt)). `--gtk` always opens the
|
||||
built-in window.
|
||||
|
||||
## Tray
|
||||
|
||||
`pdfs-tray` starts when you log in and shows whether your files are up to date, syncing, paused or
|
||||
offline. Its menu opens the app or `~/ProtonDrive`, pauses or resumes sync, and stops or connects
|
||||
the service.
|
||||
|
||||
- **GNOME** shows tray icons only with the
|
||||
[AppIndicator extension](https://extensions.gnome.org/extension/615/appindicator-support/).
|
||||
KDE Plasma and most other desktops show it without help.
|
||||
- **Hide Tray Icon** in its menu removes it until you turn it back on under
|
||||
**Preferences → General → Appearance → Show tray icon**.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Photos
|
||||
|
||||
The **Photos** page of the app shows your Proton Photos library. Photos are stored apart from
|
||||
My files, so they do not appear in `~/ProtonDrive`.
|
||||
|
||||
- [Timeline](#timeline)
|
||||
- [Albums](#albums)
|
||||
- [Places](#places)
|
||||
- [Viewer and duplicates](#viewer-and-duplicates)
|
||||
- [Importing from Google Photos](#importing-from-google-photos)
|
||||
- [Fixing photos filed under the wrong date](#fixing-photos-filed-under-the-wrong-date)
|
||||
|
||||

|
||||
|
||||
## Timeline
|
||||
|
||||
- The timeline groups photos by day, month or year as you zoom. Drag the scrubber on the right
|
||||
edge to jump through the years.
|
||||
- **On this day** shows photos taken on the same date in earlier years.
|
||||
- **Filters** narrow the timeline to images, videos or RAW files, to favorites, or to photos that
|
||||
are in no album.
|
||||
- **RAW + JPEG pairs** of the same shot show as one tile. The viewer switches between the two, and
|
||||
moving the tile to the Trash removes both.
|
||||
- **Upload** adds photos from this computer.
|
||||
|
||||
## Albums
|
||||
|
||||
**Albums** lists your own albums and the albums others share with you. Create, rename and delete
|
||||
albums, and add or remove photos. Deleting an album keeps its photos in the timeline.
|
||||
|
||||
## Places
|
||||
|
||||

|
||||
|
||||
**Places** groups photos by the town they were taken in, as cards or on a map. Town names come
|
||||
from [GeoNames](https://www.geonames.org/) data (CC BY 4.0) and the map from
|
||||
[Natural Earth](https://www.naturalearthdata.com/) (public domain). Both ship with the app, so
|
||||
nothing is fetched online.
|
||||
|
||||
A street map from [OpenFreeMap](https://openfreemap.org/) (© OpenStreetMap contributors) can be
|
||||
turned on under **Preferences → General → Appearance**. It is off by default, because the map
|
||||
server learns which areas you look at.
|
||||
|
||||
## Viewer and duplicates
|
||||
|
||||
- The viewer marks favorites, shows EXIF details, shows a photo on the map, and runs a slideshow
|
||||
(F5).
|
||||
- **Find Duplicates…** in the page menu lists byte-identical copies. Turn on **Similar** to also
|
||||
find resized or re-encoded copies and burst shots.
|
||||
|
||||
## Importing from Google Photos
|
||||
|
||||
1. Request a [Google Takeout](https://takeout.google.com/) export of Google Photos and download
|
||||
every `.zip` of it.
|
||||
2. In the app, open the **Upload** menu on the Photos page and choose **Import from Google
|
||||
Photos…**. Select all the `.zip` files at once, because a photo and its details are often in
|
||||
different parts of the export.
|
||||
|
||||
From a terminal:
|
||||
|
||||
```bash
|
||||
pdfs import-google-photos --dry-run ~/Downloads/takeout-*.zip # show what would happen
|
||||
pdfs import-google-photos --wait ~/Downloads/takeout-*.zip
|
||||
```
|
||||
|
||||
The import recreates your albums and takes capture dates and locations from the export. Photos
|
||||
already in your library are skipped, so an interrupted import can simply be run again.
|
||||
`pdfs import-status` shows how far an import is, and `pdfs cancel-import` stops it.
|
||||
|
||||
## Fixing photos filed under the wrong date
|
||||
|
||||
Photos from an earlier import sometimes sit under the day they were uploaded instead of the day
|
||||
they were taken. `pdfs redate-photos --dry-run` finds those whose file name carries a different
|
||||
date, and `pdfs redate-photos` fixes them.
|
||||
|
||||
Proton cannot change a capture date in place. So each fix uploads the photo again with the right
|
||||
date and moves the original to the Trash. Favorites and albums are kept.
|
||||
+38
-9
@@ -1,19 +1,48 @@
|
||||
# Documentation
|
||||
# Proton Drive for Linux documentation
|
||||
|
||||
## For users
|
||||
Proton Drive for Linux is an unofficial Proton Drive client for the Linux desktop. Your Drive
|
||||
appears as a folder that downloads files when you open them, local folders are backed up and kept
|
||||
in sync, and a GTK app with a tray icon and a search launcher covers Photos, sharing and more. A
|
||||
command-line client does all of it from a terminal.
|
||||
|
||||
These pages are also published as the
|
||||
[wiki](https://github.com/narrrl/proton-drive-linux/wiki), generated from the
|
||||
[`docs/`](https://github.com/narrrl/proton-drive-linux/tree/main/docs) folder of the repository.
|
||||
|
||||
## Where to start
|
||||
|
||||
- **New here?** [Getting started](GETTING_STARTED.md) takes you from installing to your first
|
||||
synced folder.
|
||||
- **Installing or updating?** [Installation](INSTALL.md) covers Fedora (COPR), Arch (AUR),
|
||||
Debian and Ubuntu, NixOS, and building from source.
|
||||
- **Something not working?** [Troubleshooting](TROUBLESHOOTING.md) starts with `pdfs diagnose`
|
||||
and goes through common problems.
|
||||
- **New computer, or the old one is gone?** [Recovering on a new computer](RECOVERY.md) gets your
|
||||
synced folders back.
|
||||
- **Quick question?** Try the [FAQ](FAQ.md).
|
||||
|
||||
## Using it
|
||||
|
||||
| Page | What it covers |
|
||||
|---|---|
|
||||
| [User guide](USER_GUIDE.md) | How the pieces fit together, the words the app uses, preferences |
|
||||
| [Files and offline access](FILES.md) | The `~/ProtonDrive` folder, keeping files available offline, the Trash, earlier versions |
|
||||
| [Synced folders](SYNCED_FOLDERS.md) | Backing up local folders, excluding files, changes waiting to upload, conflicts, pausing |
|
||||
| [Photos](PHOTOS.md) | Timeline, albums, places, duplicates, importing from Google Photos |
|
||||
| [Sharing](SHARING.md) | Invitations, public links, items shared with you |
|
||||
| [Computers](COMPUTERS.md) | Other computers in your account, syncing several computers |
|
||||
| [Search launcher and tray](LAUNCHER_AND_TRAY.md) | Finding files from a keyboard shortcut, the tray icon |
|
||||
|
||||
## Reference
|
||||
|
||||
| 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, sync issues and 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 |
|
||||
| [Security model](SECURITY_MODEL.md) | What protects your files, what is kept on this computer, what the client connects to |
|
||||
| [Changelog](CHANGELOG.md) | What changed in each release |
|
||||
|
||||
## For contributors
|
||||
## Contributing
|
||||
|
||||
| Page | What it covers |
|
||||
|---|---|
|
||||
@@ -24,5 +53,5 @@
|
||||
| [Roadmap](ROADMAP.md) | Planned work and verification still owed |
|
||||
| [Bug ledger](BUGS.md) | Every tracked defect with its analysis and status |
|
||||
|
||||
Also see [CONTRIBUTING.md](../CONTRIBUTING.md) and [SECURITY.md](../SECURITY.md) in the repository
|
||||
root.
|
||||
Also see [CONTRIBUTING.md](../CONTRIBUTING.md), and [SECURITY.md](../SECURITY.md) to report a
|
||||
vulnerability privately.
|
||||
+7
-3
@@ -3,6 +3,10 @@
|
||||
Your laptop is lost, stolen or reinstalled. This page explains what survives, and how to get
|
||||
your synced folders back on the replacement machine.
|
||||
|
||||
**In short:** sign the old machine out of your Proton account. Then, on the new one, install the
|
||||
client and sign in. On the **Computers** page, choose **Continue This Backup Here…** on the old
|
||||
computer, then **Restore Folders…** on this one.
|
||||
|
||||
- [1. What survives](#1-what-survives)
|
||||
- [2. Restore your synced folders](#2-restore-your-synced-folders)
|
||||
- [3. How the restore works](#3-how-the-restore-works)
|
||||
@@ -52,7 +56,7 @@ be decrypted.
|
||||
|
||||
### Step 1: Install and sign in
|
||||
|
||||
Install the client as described in [INSTALL.md](INSTALL.md) and sign in with `pdfs-app`. Signing
|
||||
Install the client as described in [Installation](INSTALL.md) and sign in with `pdfs-app`. Signing
|
||||
in from the app also starts the service.
|
||||
|
||||
### Step 2: Continue the old computer's backup
|
||||
@@ -100,7 +104,7 @@ progress on **Sync → Overview** or with `pdfs sync list`.
|
||||
> Restore into empty or missing directories. If a directory already holds files, for example
|
||||
> from another backup, the client cannot tell which side is newer. It keeps both: your local file
|
||||
> is renamed to a `(sync-conflict …)` copy and the version from Proton Drive is downloaded. See
|
||||
> [Conflicts](USER_GUIDE.md#conflicts).
|
||||
> [Conflicts](SYNCED_FOLDERS.md#conflicts).
|
||||
|
||||
### Step 4: Restore the rest by hand
|
||||
|
||||
@@ -191,4 +195,4 @@ This section is for the curious and for anyone debugging a restore.
|
||||
| No warning at shutdown or sign-out while changes are queued | Check `pdfs sync queue` before you retire a machine |
|
||||
| The full disaster drill (delete all local state, restore, compare byte for byte) has not been run against a live account | The individual steps are tested; the whole sequence is not |
|
||||
|
||||
Planned work is tracked in [ROADMAP.md](ROADMAP.md).
|
||||
Planned work is tracked in the [roadmap](ROADMAP.md).
|
||||
@@ -60,8 +60,12 @@ The client connects only to:
|
||||
- **Proton**, for the Drive and account API, and for the CAPTCHA page when Proton asks for one.
|
||||
- **OpenFreeMap**, for the street map of Places in Photos, only if you turn it on in Preferences. It is off by default, because the tile server learns which parts of the
|
||||
world you look at. The default map is drawn from data shipped with the app.
|
||||
- **GitHub**, once a day, to ask for the latest release, only if you turn on **Check for updates**
|
||||
in Preferences. It is off by default, because GitHub learns your IP address and, from the user
|
||||
agent, which version you run. Packages updated by a package manager (COPR, the AUR, Nix) do
|
||||
not offer the setting and never ask.
|
||||
|
||||
There is no telemetry, no analytics and no update check. The SDK can report timings of its own
|
||||
There is no telemetry and no analytics. The SDK can report timings of its own
|
||||
operations, and the client only writes them to its local log.
|
||||
|
||||
## What this client does not protect against
|
||||
@@ -75,7 +79,9 @@ operations, and the client only writes them to its local log.
|
||||
|
||||
## Packages
|
||||
|
||||
- The `.rpm` is signed with GPG.
|
||||
- The `.rpm` on the release page is signed with GPG.
|
||||
- The COPR packages are built by Fedora's COPR service from the tagged source and the vendored
|
||||
crates of the release, and are signed with the COPR project's own key.
|
||||
- The `.deb` and the `.tar.gz` are built by GitHub Actions from the tagged source, and are not
|
||||
signed.
|
||||
- The AUR packages build from the tagged source or from `main`, or install the release binaries
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Sharing
|
||||
|
||||
Share files and folders with other people, by invitation or with a public link, and use what
|
||||
others share with you.
|
||||
|
||||
- [Invite people](#invite-people)
|
||||
- [Create a public link](#create-a-public-link)
|
||||
- [See what you share](#see-what-you-share)
|
||||
- [Shared with you](#shared-with-you)
|
||||
|
||||
## Invite people
|
||||
|
||||
1. Select a file or folder and choose **Share…**.
|
||||
2. Enter one or more email addresses and choose **Can view** or **Can edit**.
|
||||
3. Send the invitation. People appear as members once they accept.
|
||||
|
||||
The admin role, which may also manage the share, is available from the command line
|
||||
(`--role admin`).
|
||||
|
||||
From a terminal:
|
||||
|
||||
```bash
|
||||
pdfs share Documents/Plans alex@example.com --role editor
|
||||
pdfs members Documents/Plans # members, pending invitations and the public link
|
||||
```
|
||||
|
||||
`pdfs share-role` changes someone's role, and `pdfs unshare` removes one person or stops sharing
|
||||
altogether. See the [command-line reference](CLI.md#sharing).
|
||||
|
||||
## Create a public link
|
||||
|
||||
In **Share…**, create a public link. Anyone with the link can open the item. You can protect the
|
||||
link with a password and let it expire on a date. From a terminal, use `pdfs public-link create`
|
||||
and `pdfs public-link remove`.
|
||||
|
||||
To change a link later, open **Link settings** under it. You can switch it between Viewer and
|
||||
Editor, set, change or remove its password, and change or remove its expiry. The address stays
|
||||
the same, so everyone who has the link keeps it. From a terminal, use `pdfs public-link update`;
|
||||
`pdfs members` shows the link's id. A link made long ago by an older Proton app may not be
|
||||
changeable; remove it and create a new one instead.
|
||||
|
||||
## See what you share
|
||||
|
||||
**Shared by me** lists everything you share. Filter it by links, by people, or by expired links.
|
||||
Copy a link that is still valid, or stop several shares at once. `pdfs shared` prints the same
|
||||
list.
|
||||
|
||||
## Shared with you
|
||||
|
||||
**Shared with me** lists what other people share with you, who shared it, and what you may do with
|
||||
it. The same items are in the `Shared with me` folder inside `~/ProtonDrive`. Items where you are
|
||||
a viewer are read-only.
|
||||
|
||||
- **Invitations** wait in a banner at the top of the page until you accept or reject them. From a
|
||||
terminal: `pdfs invitations list`, then `accept ID` or `reject ID`.
|
||||
- **Saved public links** have their own view, listed by the name of what they point at.
|
||||
`pdfs bookmarks` manages them.
|
||||
- **Leave…** a share you no longer need, or run `pdfs leave UID` with the ID from
|
||||
`pdfs shared-with-me`.
|
||||
@@ -0,0 +1,171 @@
|
||||
# Synced folders
|
||||
|
||||
A synced folder is an ordinary folder on this computer, for example `~/Documents`, that is backed
|
||||
up to Proton Drive and kept in step with it. This page covers adding one, what to do with files
|
||||
that should not be backed up, changes that are still waiting to upload, conflicts, and pausing.
|
||||
|
||||
- [Add a folder](#add-a-folder)
|
||||
- [Synced or online only](#synced-or-online-only)
|
||||
- [Move files between synced folders and My files](#move-files-between-synced-folders-and-my-files)
|
||||
- [Stop syncing a folder](#stop-syncing-a-folder)
|
||||
- [Excluding files with `.pdfsignore`](#excluding-files-with-pdfsignore)
|
||||
- [Changes waiting for Proton Drive](#changes-waiting-for-proton-drive)
|
||||
- [Conflicts](#conflicts)
|
||||
- [Pausing and limiting sync](#pausing-and-limiting-sync)
|
||||
|
||||

|
||||
|
||||
## Add a folder
|
||||
|
||||
- **In the app:** open **Computers** and choose **Add Folder to Sync**.
|
||||
- **From a terminal:** `pdfs sync add ~/Documents`
|
||||
|
||||
The folder is backed up under this computer's name. It shows on the **Computers** page of the app
|
||||
and in the Proton Drive web app.
|
||||
|
||||
From then on, changes go both ways:
|
||||
|
||||
- A file you change here uploads a few seconds after the program closes it. A file that a
|
||||
program still holds open for writing waits until it is closed.
|
||||
- A file changed somewhere else downloads on the next sync pass.
|
||||
|
||||
**Sync → Folders** in the app, and `pdfs sync list`, show every synced folder and its state.
|
||||
|
||||
To sync the same folders on two computers, see [Syncing several computers](COMPUTERS.md#syncing-several-computers).
|
||||
|
||||
## Synced or online only
|
||||
|
||||
A synced folder normally keeps a full copy on this computer. You can switch it to **Online
|
||||
only**: the files stay visible in the same place, but take no disk space until you open them, the
|
||||
same as in `~/ProtonDrive`.
|
||||
|
||||
- **In the app:** use the **Synced / Online only** switch on the folder's card.
|
||||
- **From a terminal:** `pdfs sync mode ID ondemand`, and `pdfs sync mode ID mirror` to switch
|
||||
back. `pdfs sync list` shows the ID.
|
||||
|
||||
The switch waits until everything in the folder is on Proton Drive. It refuses while the folder
|
||||
holds something that never goes there, such as an excluded `.git/` folder, a symbolic link, or a
|
||||
folder you may not read. The message names what is in the way.
|
||||
|
||||
`pdfs locations` lists every place on this computer the Proton Drive service uses.
|
||||
|
||||
## Move files between synced folders and My files
|
||||
|
||||
`~/ProtonDrive`, each online-only folder and each synced folder are separate places for your
|
||||
system. Moving a file between them with `mv` or a file manager copies and then deletes: every file
|
||||
is downloaded, uploaded again, and the original goes to the Trash.
|
||||
|
||||
Use **Move to…** in the app or `pdfs move` instead. The move happens on Proton Drive and takes
|
||||
seconds, whatever the size:
|
||||
|
||||
```sh
|
||||
pdfs move ~/Documents/Scans ~/ProtonDrive/Archive
|
||||
pdfs move ~/Documents/a.pdf ~/Documents/b.pdf ~/ProtonDrive/Archive
|
||||
```
|
||||
|
||||
The last path is the destination folder. Everything before it moves there.
|
||||
|
||||
A move out of a synced folder waits until its files are on Proton Drive. A file that is still
|
||||
uploading, has changes that are not uploaded yet, or is excluded by `.pdfsignore` stops the move,
|
||||
and the message names it. Files you already opened in an online-only folder are not downloaded
|
||||
again when they move into a synced one.
|
||||
|
||||
## Stop syncing a folder
|
||||
|
||||
**Stop Syncing** in the app, or `pdfs sync rm ID`, keeps both the local files and the copy on
|
||||
Proton Drive. To delete the copy on Proton Drive as well, tick **Also delete from Proton Drive**,
|
||||
or add `--delete-remote`.
|
||||
|
||||
## Sync a folder from scratch
|
||||
|
||||
**Sync From Scratch…** in the folder's menu, or `pdfs sync reset ID`, makes this computer forget
|
||||
what it synced in the folder before and compare it with Proton Drive again, as on the first sync.
|
||||
A file on one side only is copied to the other, and a file that differs keeps both versions.
|
||||
Nothing is deleted. Use it when a folder's files went missing on this computer and the sync
|
||||
refuses to go on (see [Troubleshooting](TROUBLESHOOTING.md#a-synced-folder-stays-in-error-and-nothing-downloads)).
|
||||
|
||||
## Excluding files with `.pdfsignore`
|
||||
|
||||
Synced folders skip build output, dependency folders and editor leftovers. Two sets of rules
|
||||
apply together:
|
||||
|
||||
1. A `.pdfsignore` file at the top of the synced folder (`.protonignore` works too). It uses the
|
||||
same syntax as `.gitignore`, including `!` to include something again:
|
||||
|
||||
```gitignore
|
||||
build/
|
||||
*.log
|
||||
!important.log
|
||||
```
|
||||
|
||||
2. The `ignore_patterns` list in `config.json`, for every synced folder (see
|
||||
[Configuration](CONFIGURATION.md#ignore_patterns)). When you have not set it, it skips
|
||||
`.git/`, `.hg/`, `.svn/`, `node_modules/`, `target/`, `.venv/`, `__pycache__/`, `*~`,
|
||||
`*.swp`, `*.tmp`, `.DS_Store` and `Thumbs.db`.
|
||||
|
||||
Rules are read again on every sync pass. Excluding never deletes anything: a file that becomes
|
||||
excluded is no longer synced, and its copy on Proton Drive stays.
|
||||
|
||||
A folder you may not read, such as a cache that a container created as root, is skipped the same
|
||||
way, and the activity feed mentions it once. Add it to `.pdfsignore` to stop the message.
|
||||
|
||||
## Changes waiting for Proton Drive
|
||||
|
||||
A change you make exists only on this computer until it reaches Proton Drive. `pdfs status`, the
|
||||
tray and **Sync → Overview** count what is still waiting, and `pdfs sync queue` lists it.
|
||||
|
||||
Sometimes Proton Drive refuses a change: the storage is full, a share was taken away, the folder
|
||||
is gone, the folder holds too many items, or Proton Drive does not accept the name. The change
|
||||
then needs you. It shows at once in **Sync → Overview**, in the tray and in `pdfs sync issues`,
|
||||
with what it means and what you can do. The file or folder also gets a warning badge in
|
||||
**Files**. Hover over it to see why.
|
||||
|
||||
Nothing is deleted. The change stays queued and is tried again. You can:
|
||||
|
||||
- **Retry** it now, for example after freeing space: the same list, or `pdfs sync retry`.
|
||||
- **Export** a file's content to keep your version: `pdfs sync export ID FILE`.
|
||||
- **Discard** the change: `pdfs sync discard ID`. It is undone here and never sent. A new file or
|
||||
folder goes away, and anything else goes back to what Proton Drive has. Export first if you want
|
||||
to keep the content.
|
||||
|
||||
Two cases are not refused, and the activity log records both:
|
||||
|
||||
- A new file or folder whose name is already taken on Proton Drive, by something this computer
|
||||
had not seen yet, lands under a `(sync-conflict …)` name. See [Conflicts](#conflicts).
|
||||
- A new file whose folder was deleted on another device in the meantime lands at the top of My
|
||||
files, so its content is not lost.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Before you switch off or wipe a computer for good, check that nothing is waiting. Changes that
|
||||
> have not reached Proton Drive are lost with the machine. See
|
||||
> [Recovery](RECOVERY.md#lost-with-the-machine).
|
||||
|
||||
## Conflicts
|
||||
|
||||

|
||||
|
||||
When the same file changed on this computer and somewhere else since the last sync, neither
|
||||
version is thrown away:
|
||||
|
||||
- The other version keeps the original name.
|
||||
- Your local version is kept as a copy named like
|
||||
`report (sync-conflict laptop 1759068180).odt`. `laptop` is the computer that made the copy,
|
||||
and the number is the time of the conflict. Copies made before 3.5.0 have no computer name.
|
||||
|
||||
Conflicts show under **Sync → Overview → Needs attention** and in `pdfs conflicts`. For each one,
|
||||
keep the original, keep the copy, or keep both under a new name:
|
||||
|
||||
```bash
|
||||
pdfs conflicts
|
||||
pdfs conflicts resolve "Documents/report (sync-conflict laptop 1759068180).odt" --keep copy
|
||||
```
|
||||
|
||||
The version you do not keep goes to the Trash, so a wrong choice can be undone.
|
||||
|
||||
## Pausing and limiting sync
|
||||
|
||||
- **Pause** from the tray, the **Sync** page, or `pdfs sync pause`. Uploads and synced folders
|
||||
wait, and opening files keeps working. `pdfs sync pause --for 1h` resumes by itself, and
|
||||
`pdfs sync resume` continues at once.
|
||||
- **Limit bandwidth** under **Preferences → General → Network**, or with
|
||||
`pdfs sync limit --up 2M --down 10M`. `0` removes a limit.
|
||||
+31
-1
@@ -116,7 +116,10 @@ for something a network filesystem cannot do.
|
||||
the move cases in every pairing of on-demand and mirror. Before a folder switches to on-demand,
|
||||
a new file is written into it; once mounted, the file must be listed with its bytes. In every
|
||||
pairing each folder must still hold the file written into it at setup.
|
||||
4. Removes everything it made, locally and on Drive, and reports `cleanup`. A run that leaves
|
||||
4. Registers a third sync folder for the recovery cases (`account` in `--list`): Sync From Scratch
|
||||
must change nothing that already matched, and a mirror folder whose local files are all gone
|
||||
must still switch to on-demand, with every file intact on Drive (B255).
|
||||
5. Removes everything it made, locally and on Drive, and reports `cleanup`. A run that leaves
|
||||
anything behind fails.
|
||||
|
||||
Cleanup finds remote folders by uid, never by name, so nothing of yours is touched. It removes
|
||||
@@ -199,6 +202,15 @@ Besides the syscall contract:
|
||||
both directions, several sources in one `pdfs move`, and a tree through My files and out again.
|
||||
The refusals must move nothing and lose nothing: a name already taken, a folder into itself, a
|
||||
mirrored source holding something Drive lacks, a file still open for writing.
|
||||
- **`copy_file_range`.** A 17 MiB copy that takes several calls, copies into the middle of a file
|
||||
and past its end, a short copy at the source's end, and a copy from a file whose writes are not
|
||||
released yet. On a live mount the results are also read back from Drive.
|
||||
- **Copies, sizes and links on Drive** (My files only). `pdfs copy` of a tree and of a file beside
|
||||
itself, read back as listed and after `pdfs refresh`, with nothing queued; the refusal while a
|
||||
file has writes Drive lacks, and never the version before a save (B256). `pdfs du` counts every
|
||||
item below a folder and keeps counting one moved to the trash. `pdfs public-link update` keeps
|
||||
the URL and every option it was not asked to change, and refuses a link id that is not the
|
||||
folder's.
|
||||
- **The CLI.** `pdfs` into a closed pipe exits 0 with nothing on stderr; `pdfs mkdir`, `rename`
|
||||
and `rm` by local path, and their refusals.
|
||||
|
||||
@@ -263,3 +275,21 @@ typo it is. A release run leaves it unset.
|
||||
|
||||
What no automated run covers yet is listed under
|
||||
[Verification still owed](ROADMAP.md#verification-still-owed).
|
||||
|
||||
### By hand in the app
|
||||
|
||||
The suite drives the CLI, not the app. Before a release that changes one of these screens, check
|
||||
it by hand on a test account:
|
||||
|
||||
1. **Make a Copy** on a file in My files: the copy appears beside it with ` (1)`, and opens with
|
||||
the same content. **Copy To…** a folder into another folder: the whole tree arrives.
|
||||
2. Make a Copy on a file that is still open in an editor with unsaved changes: the app refuses,
|
||||
and no copy appears.
|
||||
3. **Details** of a folder: **Size on Proton Drive** shows a size and an item count, and its
|
||||
tooltip says items in the trash are counted. Details of a file still shows **Size**.
|
||||
4. **Share → Link settings** on a link with a password: change **Link role**. The link in the
|
||||
dialog stays the same, and **Remove the password** is still offered. Then remove the password
|
||||
and pick an expiry under **Expires**: the link row shows when it expires.
|
||||
5. **Sync From Scratch…** on a mirror folder: after the pass, the folder holds the same files and
|
||||
no conflict copies.
|
||||
6. Each of the above in a second language (`LANGUAGE=de pdfs-app`): no English left.
|
||||
+150
-84
@@ -20,6 +20,11 @@ information listed under [Reporting a problem](#reporting-a-problem).
|
||||
Checks the installation and prints a report. It does not need the service, because the state
|
||||
worth diagnosing is often the one where the service will not start.
|
||||
|
||||
When you are signed in but the service does not answer, it also asks Proton which of your keys
|
||||
open, under **Keys**: each account key, each address key, and the address key your My Files
|
||||
share is encrypted to. A key that does not open is named with the key it depends on. The section
|
||||
shows key IDs, which are not secret, so you can post it in an issue.
|
||||
|
||||
```console
|
||||
$ pdfs diagnose
|
||||
Paths
|
||||
@@ -57,7 +62,7 @@ journalctl --user -u proton-drive.service -b -p warning # warnings since boot
|
||||
```
|
||||
|
||||
For more detail, raise the log level with a drop-in (see
|
||||
[CONFIGURATION.md](CONFIGURATION.md#the-systemd-service)) and restart the service.
|
||||
[Configuration](CONFIGURATION.md#the-systemd-service)) and restart the service.
|
||||
|
||||
### Service state
|
||||
|
||||
@@ -68,96 +73,124 @@ pdfs status
|
||||
|
||||
## Signing in
|
||||
|
||||
**"Proton is asking for a CAPTCHA"** — Proton wants human verification, usually on a VPN or an
|
||||
unfamiliar network. `pdfs login` prints the verification page's address: open it in your browser,
|
||||
complete it, then press Enter. The app shows the page in its own window.
|
||||
### Proton is asking for a CAPTCHA
|
||||
|
||||
**"Verify in Your Browser"** — WebKit, which shows the CAPTCHA page in the app, could not start
|
||||
its sandbox. Hardened systems such as secureblue keep its bubblewrap from the journal socket, and
|
||||
the log then shows `bwrap: Can't get type of source /run/systemd/journal/socket: Permission
|
||||
denied` and `Failed to fully launch dbus-proxy`. Choose **Open Verification Page**, complete the
|
||||
page in your browser, then choose **Continue**. If that sign-in is refused too, check that `bwrap`
|
||||
and `xdg-dbus-proxy` are installed. As a last resort, sign in once with the sandbox switched off
|
||||
for this one run:
|
||||
Proton wants to check that you are human, usually on a VPN or an unfamiliar network. The app
|
||||
shows the page in its own window. `pdfs login` prints the page's address instead: open it in your
|
||||
browser, complete it, then press Enter.
|
||||
|
||||
### "Verify in Your Browser"
|
||||
|
||||
WebKit, which shows the CAPTCHA page in the app, could not start its sandbox. Hardened systems
|
||||
such as secureblue keep its bubblewrap away from the journal socket. The log then shows
|
||||
`bwrap: Can't get type of source /run/systemd/journal/socket: Permission denied` and
|
||||
`Failed to fully launch dbus-proxy`.
|
||||
|
||||
1. Choose **Open Verification Page** and complete the page in your browser.
|
||||
2. Choose **Continue**.
|
||||
|
||||
If that sign-in is refused too, check that `bwrap` and `xdg-dbus-proxy` are installed. As a last
|
||||
resort, quit the app and sign in once with the sandbox switched off for this one run:
|
||||
|
||||
```bash
|
||||
WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1 pdfs-app
|
||||
```
|
||||
|
||||
Quit the app first, so this starts a new instance. The page then runs without WebKit's isolation,
|
||||
so use this only for the sign-in. The stored session keeps working without it. Signing in from
|
||||
your usual network, without a VPN, often avoids the CAPTCHA in the first place.
|
||||
The page then runs without WebKit's isolation, so use this only for the sign-in. The stored
|
||||
session keeps working without it. Signing in from your usual network, without a VPN, often avoids
|
||||
the CAPTCHA in the first place.
|
||||
|
||||
**"This version of the app is no longer supported"** or **"Proton is temporarily refusing
|
||||
sign-in from this device"** — Proton blocks sign-in for a while after too many attempts or from a
|
||||
network it distrusts, and sends the first message even when the release is current. Do not retry
|
||||
for several hours, up to a day, then sign in once. If it still fails, run
|
||||
`RUST_LOG=debug pdfs login` and look for `code=` in the `proton api request failed` line: 2028 is
|
||||
this block, 5003 is a release Proton no longer accepts.
|
||||
### "This version of the app is no longer supported" or "Proton is temporarily refusing sign-in from this device"
|
||||
|
||||
**Sign-in works but is forgotten after a restart** — no Secret Service provider is running, or its
|
||||
keyring is locked. Install and unlock GNOME Keyring, KWallet or KeePassXC (with Secret Service
|
||||
enabled). `pdfs diagnose` shows `keyring session` as failing in that case. When the app finds
|
||||
a saved sign-in it cannot read, it says so above the sign-in form and opens on its own once the
|
||||
keyring is unlocked. You don't have to sign in again.
|
||||
Proton blocks sign-in for a while after too many attempts, or from a network it distrusts. It sends
|
||||
the first message even when your version is current. Do not retry for several hours, up to a day,
|
||||
then sign in once.
|
||||
|
||||
**Asked for the mailbox password after every reboot** — the account has a separate mailbox
|
||||
password (two-password mode). Such accounts are locked by default: the unlock lasts until you log
|
||||
out or restart. To stay unlocked, turn on **Keep unlocked on this computer** in the unlock dialog,
|
||||
or run `pdfs unlock --remember`. Either one stores the unlock in the system keyring.
|
||||
[SECURITY_MODEL.md](SECURITY_MODEL.md) explains what that trades away.
|
||||
If it still fails, run `RUST_LOG=debug pdfs login` and look for `code=` in the
|
||||
`proton api request failed` line. 2028 is this block; 5003 is a release Proton no longer accepts.
|
||||
|
||||
### Sign-in works but is forgotten after a restart
|
||||
|
||||
No Secret Service provider is running, or its keyring is locked. Install and unlock GNOME
|
||||
Keyring, KWallet or KeePassXC (with Secret Service turned on). `pdfs diagnose` then shows
|
||||
`keyring session` as failing.
|
||||
|
||||
When the app finds a saved sign-in it cannot read, it says so above the sign-in form, and opens
|
||||
by itself once the keyring is unlocked. You do not have to sign in again.
|
||||
|
||||
### Asked for the mailbox password after every reboot
|
||||
|
||||
The account has a separate mailbox password (two-password mode). Such accounts are locked by
|
||||
default: the unlock lasts until you log out or restart. To stay unlocked, turn on **Keep unlocked
|
||||
on this computer** in the unlock dialog, or run `pdfs unlock --remember`. Either one stores the
|
||||
unlock in the system keyring. The [security model](SECURITY_MODEL.md) explains what that trades
|
||||
away.
|
||||
|
||||
## The Proton Drive folder
|
||||
|
||||
> [!TIP]
|
||||
> When the service hangs, any program that touches the Proton Drive folder can hang with it,
|
||||
> including `ls` and your shell's prompt. Prefix exploratory commands with `timeout 5`.
|
||||
> including `ls` and your shell's prompt. Put `timeout 5` in front of commands you try.
|
||||
|
||||
**`Transport endpoint is not connected`** — the service stopped without unmounting. Unmount and
|
||||
restart:
|
||||
### `Transport endpoint is not connected`
|
||||
|
||||
The service stopped without unmounting. Unmount and restart:
|
||||
|
||||
```bash
|
||||
fusermount3 -uz ~/ProtonDrive
|
||||
systemctl --user restart proton-drive.service
|
||||
```
|
||||
|
||||
**`fetch My Files root: … failed to decrypt: missing key`** in the service log — the account
|
||||
keys cannot open your Drive volume, which happens after a password reset. Proton locks the volume
|
||||
that was encrypted to the old keys. From 3.4.1 the client starts a new, empty My Files volume
|
||||
in that case. Your old files stay in the locked volume: open [drive.proton.me](https://drive.proton.me)
|
||||
and restore them there with your previous password.
|
||||
### The folder is empty or missing
|
||||
|
||||
**The folder is empty or missing** — check that the service runs and is signed in
|
||||
(`pdfs status`). The service waits for a stored session before it mounts.
|
||||
Check that the service runs and is signed in, with `pdfs status`. The service waits for a stored
|
||||
session before it shows your files.
|
||||
|
||||
**Everything in the folder hangs** — find out what the service is doing, then restart it:
|
||||
### `fetch My Files root: … failed to decrypt: missing key` in the log
|
||||
|
||||
Your account keys cannot open your Drive, which happens after a password reset: Proton locks the
|
||||
files that were encrypted with the old keys. Since 3.4.1 the client then starts a new, empty My
|
||||
files. Your old files are still there, locked: open [drive.proton.me](https://drive.proton.me) and
|
||||
restore them with your previous password. If your files are not locked and the error stays, run
|
||||
`pdfs diagnose` and post its **Keys** section in an issue.
|
||||
|
||||
### Everything in the folder hangs
|
||||
|
||||
Find out what the service is doing, then restart it:
|
||||
|
||||
```bash
|
||||
timeout 10 pdfs diagnostics
|
||||
systemctl --user restart proton-drive.service
|
||||
```
|
||||
|
||||
The service watchdog restarts a service that stops answering for two minutes by itself. Attach the
|
||||
`pdfs diagnostics` output to a bug report; it shows which worker is stuck.
|
||||
The service restarts by itself when it stops answering for two minutes. Attach the
|
||||
`pdfs diagnostics` output to a bug report; it shows which part is stuck.
|
||||
|
||||
**A change made on another device does not show up** — changes arrive through Proton's event
|
||||
stream, normally within seconds. To fetch a folder again at once:
|
||||
### A change made on another device does not show up
|
||||
|
||||
Changes from other devices normally arrive within seconds. To fetch a folder again at once:
|
||||
|
||||
```bash
|
||||
pdfs refresh Documents
|
||||
```
|
||||
|
||||
**"Permission denied" when writing** — the item is in a share where you are a viewer, or under
|
||||
`Shared with me/`, which is read-only as a folder. Check your role with `pdfs shared-with-me`.
|
||||
### "Permission denied" when writing
|
||||
|
||||
**`ln` or another program fails with "Operation not permitted"** — Proton Drive cannot store
|
||||
symbolic links, hard links, device files or FIFOs, so creating one fails at once. The error is
|
||||
intentional. Use a synced folder for projects that need them, or exclude those paths.
|
||||
The item is in a share where you are a viewer, or directly under `Shared with me/`, which is
|
||||
read-only. Check your role with `pdfs shared-with-me`.
|
||||
|
||||
### `ln` or another program fails with "Operation not permitted"
|
||||
|
||||
Proton Drive cannot store symbolic links, hard links, device files or FIFOs, so creating one fails
|
||||
at once. This is on purpose. For projects that need them, use a
|
||||
[synced folder](SYNCED_FOLDERS.md) and exclude those paths.
|
||||
|
||||
## Uploads and sync
|
||||
|
||||
**A change shows here but not on other devices** — changes are recorded on this computer first
|
||||
and sent from a queue. List what is still waiting, and what needs attention:
|
||||
### A change shows here but not on other devices
|
||||
|
||||
Changes are recorded on this computer first and sent from a queue. List what is still waiting,
|
||||
and what needs you:
|
||||
|
||||
```bash
|
||||
pdfs sync queue
|
||||
@@ -165,72 +198,105 @@ pdfs sync issues
|
||||
```
|
||||
|
||||
The app shows the same on **Sync → Overview**. A change waits while the service is offline or
|
||||
sync is paused (`pdfs status`), and backs off after a failure. To retry at once:
|
||||
sync is paused (`pdfs status`), and waits longer after each failure. To retry at once:
|
||||
|
||||
```bash
|
||||
pdfs sync retry # every failed operation
|
||||
pdfs sync retry 42 # one operation
|
||||
pdfs sync retry # every failed change
|
||||
pdfs sync retry 42 # one change
|
||||
```
|
||||
|
||||
A change Proton Drive refuses (storage full, access taken away, folder gone) is listed by
|
||||
`pdfs sync issues` with what to do. It stays queued until you fix the cause or drop it. Dropping
|
||||
it undoes it here; save a file's content first if you want to keep it:
|
||||
it undoes it here, so save a file's content first if you want to keep it:
|
||||
|
||||
```bash
|
||||
pdfs sync export 42 ~/Desktop/
|
||||
pdfs sync discard 42
|
||||
```
|
||||
|
||||
If you suspect the local-first behaviour of 3.0.0 itself, `"local_first": false` in `config.json`
|
||||
makes the Proton Drive folder wait for Proton Drive again while online (see
|
||||
[CONFIGURATION.md](CONFIGURATION.md#local_first)), and please report the problem.
|
||||
[Changes waiting for Proton Drive](SYNCED_FOLDERS.md#changes-waiting-for-proton-drive) explains
|
||||
the choices.
|
||||
|
||||
**A file in a synced folder does not upload** — check that:
|
||||
If you suspect that the instant, queued changes introduced in 3.0.0 are the problem,
|
||||
`"local_first": false` in `config.json` makes the Proton Drive folder wait for Proton Drive again
|
||||
while online (see [Configuration](CONFIGURATION.md#local_first)). Please report the problem too.
|
||||
|
||||
- it is not matched by `.pdfsignore` or `ignore_patterns`;
|
||||
### A file in a synced folder does not upload
|
||||
|
||||
Check that:
|
||||
|
||||
- it is not excluded by `.pdfsignore` or `ignore_patterns`;
|
||||
- no program still holds it open for writing (such files wait until closed);
|
||||
- sync is not paused (`pdfs status`, tray);
|
||||
- the folder has no error on **Sync → Folders**.
|
||||
- sync is not paused (`pdfs status`, or the tray);
|
||||
- the folder shows no error on **Sync → Folders**.
|
||||
|
||||
`pdfs sync now` starts a pass immediately.
|
||||
`pdfs sync now` starts a sync pass at once.
|
||||
|
||||
**Files named `(sync-conflict …)` appeared** — the file changed in two places at once. Nothing was
|
||||
lost; see [Conflicts](USER_GUIDE.md#conflicts) to pick a version.
|
||||
### A synced folder stays in error and nothing downloads
|
||||
|
||||
**Photos show the wrong date after a Google Takeout import** — run
|
||||
`pdfs redate-photos --dry-run`, see [USER_GUIDE.md](USER_GUIDE.md#importing-from-google-photos).
|
||||
The log says `every one of the N synced paths is missing locally; refusing to trash them on
|
||||
Drive`. The folder synced before, and now none of its files are on this computer. The sync
|
||||
stops so that it does not delete them on Proton Drive too. Check that the folder is the right
|
||||
one and that its disk is mounted. To get the files back, choose **Sync From Scratch…** in the
|
||||
folder's menu on **Sync → Folders**, or run:
|
||||
|
||||
```bash
|
||||
pdfs sync list # find the folder's ID
|
||||
pdfs sync reset 1
|
||||
```
|
||||
|
||||
This downloads what Proton Drive has and uploads what only this computer has. Nothing is deleted.
|
||||
If you deleted the files on purpose, delete them on Proton Drive too, or stop syncing the folder.
|
||||
|
||||
### Files named `(sync-conflict …)` appeared
|
||||
|
||||
The file changed in two places at once. Nothing was lost. See
|
||||
[Conflicts](SYNCED_FOLDERS.md#conflicts) to pick a version.
|
||||
|
||||
### Photos show the wrong date after a Google Takeout import
|
||||
|
||||
Run `pdfs redate-photos --dry-run`. See
|
||||
[Fixing photos filed under the wrong date](PHOTOS.md#fixing-photos-filed-under-the-wrong-date).
|
||||
|
||||
## Disk space
|
||||
|
||||
| What | Check | Reduce |
|
||||
|---|---|---|
|
||||
| Content cache | `pdfs cache inspect` | Lower **Preferences → Storage → Cache size limit**, or `pdfs cache clear` |
|
||||
| Cache of opened files | `pdfs cache inspect` | Lower **Preferences → Storage → Cache size limit**, or `pdfs cache clear` |
|
||||
| Available-offline files | `pdfs pins` | `pdfs unpin PATH` |
|
||||
| Metadata database | `pdfs cache inspect` (reclaimable) | `pdfs cache vacuum` |
|
||||
| Synced folders | `pdfs sync list` | Switch large folders to online only: `pdfs sync mode ID ondemand` |
|
||||
|
||||
`pdfs cache vacuum` locks the database while it runs and needs free space for a second copy of it.
|
||||
`pdfs cache vacuum` locks the database while it runs, and needs free space for a second copy of
|
||||
it.
|
||||
|
||||
## Desktop integration
|
||||
|
||||
**No tray icon on GNOME** — install and enable the AppIndicator and KStatusNotifierItem Support
|
||||
extension. Other desktops show the icon natively. If you hid it from its own menu, turn it back on
|
||||
under **Preferences → General → Appearance**.
|
||||
### No tray icon on GNOME
|
||||
|
||||
**The search launcher does nothing** — `pdfs-prompt` needs the service. Run it from a terminal to
|
||||
see its error. For `--fzf`, install `fzf` and a supported terminal.
|
||||
Install and turn on the
|
||||
[AppIndicator and KStatusNotifierItem Support](https://extensions.gnome.org/extension/615/appindicator-support/)
|
||||
extension. Other desktops show the icon without help. If you hid it from its own menu, turn it
|
||||
back on under **Preferences → General → Appearance**.
|
||||
|
||||
**The app shows "Not responding"** — the service runs but does not answer within five seconds.
|
||||
See [Everything in the folder hangs](#the-proton-drive-folder).
|
||||
### The search launcher does nothing
|
||||
|
||||
`pdfs-prompt` needs the service. Run it from a terminal to see its error. For `--fzf`, install
|
||||
`fzf` and a supported terminal.
|
||||
|
||||
### The app shows "Not responding"
|
||||
|
||||
The service runs but does not answer within five seconds. See
|
||||
[Everything in the folder hangs](#everything-in-the-folder-hangs).
|
||||
|
||||
## Start over with a clean local state
|
||||
|
||||
This removes the local database and cache so they are rebuilt from Proton Drive. It is the fix for
|
||||
a damaged database and the only way to downgrade to an older release.
|
||||
This removes the local database and cache, so they are built again from Proton Drive. It fixes a
|
||||
damaged database, and it is the only way to go back to an older release.
|
||||
|
||||
> [!WARNING]
|
||||
> Content in `staging/` and `recovery/` has not reached Proton Drive yet. Deleting it loses those
|
||||
> changes permanently. Wait until `pdfs sync queue` is empty.
|
||||
> changes for good. Wait until `pdfs sync queue` is empty.
|
||||
|
||||
```bash
|
||||
pdfs sync queue # must list nothing
|
||||
@@ -241,8 +307,8 @@ systemctl --user start proton-drive.service
|
||||
```
|
||||
|
||||
Your session is in the keyring and your settings in `~/.config`, so you stay signed in. Synced
|
||||
folders are not re-attached automatically; restore them with `pdfs sync restore` as described in
|
||||
[RECOVERY.md](RECOVERY.md#2-restore-your-synced-folders). Delete the `.bak` directories once
|
||||
folders are not attached again by themselves. Restore them with `pdfs sync restore`, as described
|
||||
in [Recovery](RECOVERY.md#2-restore-your-synced-folders). Delete the `.bak` directories once
|
||||
everything is back.
|
||||
|
||||
## Reporting a problem
|
||||
@@ -253,7 +319,7 @@ Include:
|
||||
- the output of `pdfs diagnose`;
|
||||
- relevant log lines from `journalctl --user -u proton-drive.service`;
|
||||
- for a hang, the output of `timeout 10 pdfs diagnostics`;
|
||||
- the steps that reproduce it.
|
||||
- the steps that make it happen.
|
||||
|
||||
Logs contain file names. Remove anything private before posting. Report security problems
|
||||
privately as described in [SECURITY.md](../SECURITY.md).
|
||||
privately, as described in [SECURITY.md](../SECURITY.md).
|
||||
+29
-358
@@ -1,29 +1,27 @@
|
||||
# User guide
|
||||
|
||||
How Proton Drive for Linux works and how to use it day to day. For installation see
|
||||
[INSTALL.md](INSTALL.md); every command mentioned here is described in [CLI.md](CLI.md).
|
||||
How Proton Drive for Linux works and how to use it day to day. To install it, see
|
||||
[Installation](INSTALL.md). If you are new, [Getting started](GETTING_STARTED.md) is the quicker
|
||||
read.
|
||||
|
||||
- [How it works](#how-it-works)
|
||||
- [My files](#my-files)
|
||||
- [Keeping files on this computer](#keeping-files-on-this-computer)
|
||||
- [Syncing local folders](#syncing-local-folders)
|
||||
- [Changes waiting for Proton Drive](#changes-waiting-for-proton-drive)
|
||||
- [Conflicts](#conflicts)
|
||||
- [Pausing and limiting sync](#pausing-and-limiting-sync)
|
||||
- [Photos](#photos)
|
||||
- [Sharing](#sharing)
|
||||
- [Trash and version history](#trash-and-version-history)
|
||||
- [Computers](#computers)
|
||||
- [Search launcher](#search-launcher)
|
||||
- [Choosing how files open](#choosing-how-files-open)
|
||||
- [Tray](#tray)
|
||||
- [Preferences](#preferences)
|
||||
## Topics
|
||||
|
||||
| Page | What it covers |
|
||||
|---|---|
|
||||
| [Files and offline access](FILES.md) | The `~/ProtonDrive` folder, keeping files available offline, the Trash, earlier versions |
|
||||
| [Synced folders](SYNCED_FOLDERS.md) | Backing up local folders, excluding files, changes waiting to upload, conflicts, pausing |
|
||||
| [Photos](PHOTOS.md) | Timeline, albums, places, duplicates, importing from Google Photos |
|
||||
| [Sharing](SHARING.md) | Invitations, public links, items shared with you |
|
||||
| [Computers](COMPUTERS.md) | Other computers in your account, syncing several computers |
|
||||
| [Search launcher and tray](LAUNCHER_AND_TRAY.md) | Finding files from a keyboard shortcut, the tray icon |
|
||||
|
||||
Every command mentioned in these pages is listed in the
|
||||
[command-line reference](CLI.md).
|
||||
|
||||
## How it works
|
||||
|
||||
A background service, `proton-drive.service`, runs `pdfs daemon`. It holds your session, talks to
|
||||
Proton Drive, and keeps your Drive mounted as a folder. Everything else is a front end that asks
|
||||
the service to do things over a local socket:
|
||||
Proton Drive, and shows your Drive as a folder. Everything else asks the service to do things:
|
||||
|
||||
| Program | What it is |
|
||||
|---|---|
|
||||
@@ -32,357 +30,30 @@ the service to do things over a local socket:
|
||||
| `pdfs-prompt` | A search launcher for a keyboard shortcut |
|
||||
| `pdfs` | The command-line client, for everything above and for scripts |
|
||||
|
||||
Your Drive, your file names and your photos are end-to-end encrypted by Proton. On this computer,
|
||||
the service must decrypt what it shows you, and it keeps a decrypted cache and index on disk. Use
|
||||
full-disk or home-directory encryption. [ARCHITECTURE.md §10](ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext)
|
||||
Your files, file names and photos are end-to-end encrypted by Proton. To show them to you, the
|
||||
service decrypts them on this computer and keeps a cache, which is not encrypted. Use full-disk or
|
||||
home-folder encryption. The [security model](SECURITY_MODEL.md#what-is-kept-on-this-computer)
|
||||
lists exactly what is stored.
|
||||
|
||||
The app uses these words consistently, and so does this guide:
|
||||
## Words used in the app
|
||||
|
||||
The app uses these words, and so do these pages:
|
||||
|
||||
| Term | Meaning |
|
||||
|---|---|
|
||||
| **My files** | The root of your Drive |
|
||||
| **Proton Drive folder** | Where My files appears on disk, `~/ProtonDrive` by default |
|
||||
| **Online only** | A file or folder whose content downloads when opened |
|
||||
| **My files** | Everything in your Proton Drive, apart from Photos and computer backups |
|
||||
| **Proton Drive folder** | Where My files appears on this computer, `~/ProtonDrive` by default |
|
||||
| **Online only** | A file or folder that downloads when you open it |
|
||||
| **Available offline** | A file, or a folder with everything in it, kept on this computer |
|
||||
| **Synced folder** | A local folder with a full copy on this computer, backed up to Proton Drive |
|
||||
| **Proton Drive service** | The background service (`proton-drive.service`) |
|
||||
|
||||
## My files
|
||||
|
||||
My files is mounted at `~/ProtonDrive`. Every file and folder appears immediately, but content
|
||||
downloads only when a program reads it:
|
||||
|
||||
- **Reads fetch only what is needed.** A video player that seeks, or a tool that reads a file
|
||||
header, downloads only those parts. Media streams while it plays.
|
||||
- **Changes work at local speed.** Creating, renaming, moving and deleting files and folders
|
||||
returns at once, online or offline. When a program closes a file it wrote, the new content is
|
||||
stored on disk. Every change is queued and sent to Proton Drive in the background; the changes
|
||||
to one file are sent in the order you made them. The queue survives network loss, restarts and
|
||||
crashes. See [Changes waiting for Proton Drive](#changes-waiting-for-proton-drive).
|
||||
- **Recently read content is cached** on disk up to the cache size limit (5 GiB by default) and
|
||||
evicted oldest first.
|
||||
- **Items shared with you** appear under `Shared with me/` inside the Proton Drive folder. Shares
|
||||
where you are a viewer are read-only.
|
||||
|
||||
The **My files** page of the app browses the same tree without going through the mount. It has a
|
||||
grid and a list view, sorting, thumbnails (including camera RAW previews), multi-select and drag
|
||||
and drop upload. **Details** (Alt+Enter) shows size, dates, sharing and version history for the
|
||||
selected item.
|
||||
|
||||
Symbolic links, hard links, device files and FIFOs cannot be stored on Proton Drive. Programs that
|
||||
try to create them get a clean error, not a hang or `EIO`.
|
||||
|
||||
## Keeping files on this computer
|
||||
|
||||
Turn on **Available offline** for a file or folder in My files, or run `pdfs pin <path>`. A file
|
||||
is downloaded completely and never evicted from the cache. For a folder, every file in it and in
|
||||
its subfolders downloads in the background, and the tray and the app show the progress.
|
||||
`pdfs pins` lists what is kept, and **Preferences → Storage → Available offline** shows it in the
|
||||
app.
|
||||
|
||||
Turning it off (`pdfs unpin <path>`) frees the space. The files stay on Proton Drive. A file inside
|
||||
a folder kept offline stays with its folder: turn the folder off instead.
|
||||
|
||||
## Syncing local folders
|
||||
|
||||
A synced folder is an ordinary folder on this computer, for example `~/Documents`, that is
|
||||
backed up to Proton Drive under this computer's name. It appears on the **Computers** page of the
|
||||
app and in the web app.
|
||||
|
||||
- Add one with **Add Folder to Sync** on the **Computers** page, or `pdfs sync add ~/Documents`.
|
||||
- Changes flow both ways. Local edits upload a few seconds after the file is closed; remote edits
|
||||
download on the next pass.
|
||||
- A file that another program still holds open for writing is not uploaded until it is closed.
|
||||
- **Sync → Folders** in the app, and `pdfs sync list`, show every folder and its state.
|
||||
|
||||
Each synced folder can also be switched to **Online only**. The local copy is replaced by a mount
|
||||
of the folder, so the files stay visible but take no disk space until opened. Switch with the
|
||||
**Synced / Online only** switch on the folder's card, or `pdfs sync mode <id> ondemand` and back
|
||||
with `pdfs sync mode <id> mirror`. The switch waits until everything in the folder is on Proton
|
||||
Drive, and refuses while the folder holds something that never goes there: an ignored path such
|
||||
as `.git/`, a symlink, or a folder you may not read. The message names it. `pdfs locations` lists every local place the service occupies.
|
||||
|
||||
### Moving between synced folders and My files
|
||||
|
||||
`~/ProtonDrive` and each online-only folder are separate mounts, and a mirrored folder is a plain
|
||||
folder on disk. `mv`, and dragging in a file manager, cannot rename across them, so they copy
|
||||
and then delete: every file is downloaded, uploaded again, and the original goes to the Trash.
|
||||
|
||||
Use `pdfs move <path> <folder>` or **Move to…** in the app instead. The move happens on Proton
|
||||
Drive, so it takes seconds whatever the size. For example:
|
||||
|
||||
```sh
|
||||
pdfs move ~/Documents/Scans ~/ProtonDrive/Archive
|
||||
pdfs move ~/Documents/a.pdf ~/Documents/b.pdf ~/ProtonDrive/Archive
|
||||
```
|
||||
|
||||
The last path is the destination folder; everything before it moves there.
|
||||
Files already opened in an online-only folder are not downloaded again when
|
||||
they move into a mirrored one.
|
||||
|
||||
A move out of a synced folder waits until its files are on Proton Drive. A file that is still
|
||||
uploading, has changes that are not uploaded, or is excluded by `.pdfsignore` stops the move,
|
||||
and the message names it.
|
||||
|
||||
Removing a synced folder (**Stop Syncing** or `pdfs sync rm <id>`) keeps the local files and the
|
||||
copy on Proton Drive. Add `--delete-remote` (or tick **Also delete from Proton Drive**) to delete
|
||||
the copy on Proton Drive as well.
|
||||
|
||||
### Excluding files with `.pdfsignore`
|
||||
|
||||
Synced folders skip build output, dependency folders and editor leftovers. Rules come from two
|
||||
places, and both apply:
|
||||
|
||||
1. A `.pdfsignore` file at the root of the synced folder (`.protonignore` also works), using
|
||||
gitignore syntax, including `!` to re-include:
|
||||
|
||||
```gitignore
|
||||
build/
|
||||
*.log
|
||||
!important.log
|
||||
```
|
||||
|
||||
2. The global `ignore_patterns` list in `config.json` (see
|
||||
[CONFIGURATION.md](CONFIGURATION.md#ignore_patterns)). When unset, it defaults to `.git/`,
|
||||
`.hg/`, `.svn/`, `node_modules/`, `target/`, `.venv/`, `__pycache__/`, `*~`, `*.swp`, `*.tmp`,
|
||||
`.DS_Store` and `Thumbs.db`.
|
||||
|
||||
Rules are re-read on every pass. Ignoring never deletes anything: a file that becomes ignored is
|
||||
simply no longer tracked, and its copy on Proton Drive stays.
|
||||
|
||||
A folder you may not read, such as a cache a container created as root, is skipped the same way,
|
||||
and the activity feed names it once. Add it to `.pdfsignore` to stop that message.
|
||||
|
||||
## Changes waiting for Proton Drive
|
||||
|
||||
A change you made in the Proton Drive folder exists only on this computer until it reaches
|
||||
Proton Drive. `pdfs status`, the tray and **Sync → Overview** count what is still waiting, and
|
||||
`pdfs sync queue` lists it.
|
||||
|
||||
Sometimes Proton Drive refuses a change: the storage is full, a share was taken away, the folder
|
||||
is gone, the folder holds too many items, or Drive does not accept a name. The change then needs
|
||||
your attention. It shows at once in **Sync → Overview**, in the tray, and in `pdfs sync issues`,
|
||||
with what it means and what you can do. A file or folder with such a change carries a warning
|
||||
badge in **Files**; hover it to see why.
|
||||
|
||||
Nothing is deleted. The change stays queued and is retried. You can:
|
||||
|
||||
- **Retry** it now, from the same list or with `pdfs sync retry`, for example after freeing space.
|
||||
- **Export** a file's content to keep your version (`pdfs sync export <id> <file>`).
|
||||
- **Discard** the change (`pdfs sync discard <id>`). It is undone here and never sent: a new file
|
||||
or folder goes away, anything else goes back to what Proton Drive has. Export first if you want
|
||||
to keep the content.
|
||||
|
||||
Two cases are not refused, and the activity log records both:
|
||||
|
||||
- A new file or folder whose name is already taken on Proton Drive by something this computer had
|
||||
not seen yet lands under a `(sync-conflict …)` name, as described below.
|
||||
- A new file whose folder was deleted on another device meanwhile lands at the top of My files,
|
||||
so its content is not lost.
|
||||
|
||||
Before you shut down a computer for good, check that nothing is waiting: see
|
||||
[RECOVERY.md](RECOVERY.md#lost-with-the-machine).
|
||||
|
||||
## Conflicts
|
||||
|
||||
When the same file changed on this computer and somewhere else since the last sync, neither side
|
||||
is thrown away. The local version is kept as a copy named like
|
||||
`report (sync-conflict laptop 1759068180).odt`, where `laptop` is the name of the computer that
|
||||
made the copy and the number is the time of the conflict in seconds since 1970. The remote
|
||||
version takes the original name. Copies made before 3.5.0 have no computer name.
|
||||
|
||||
Conflicts appear under **Sync → Overview → Needs attention** and in `pdfs conflicts`. For each
|
||||
one, keep the original, keep the copy, or keep both under a new name:
|
||||
|
||||
```bash
|
||||
pdfs conflicts
|
||||
pdfs conflicts resolve "Documents/report (sync-conflict laptop 1759068180).odt" --keep copy
|
||||
```
|
||||
|
||||
The version you do not keep goes to the Trash, so a wrong choice can be undone.
|
||||
|
||||
## Pausing and limiting sync
|
||||
|
||||
- **Pause** from the tray, the Sync page, or `pdfs sync pause`. Uploads and synced folders wait;
|
||||
reading files keeps working. `pdfs sync resume` continues.
|
||||
- **Limit bandwidth** under **Preferences → General → Network**, or with
|
||||
`pdfs sync limit --up 2M --down 10M`. `0` removes a limit.
|
||||
|
||||
## Photos
|
||||
|
||||
The **Photos** page shows your Proton Photos library. Photos are stored separately from My files
|
||||
and do not appear in the Proton Drive folder.
|
||||
|
||||
- **Timeline** groups photos by day, month or year as you zoom. Drag the scrubber on the right edge
|
||||
to jump through the years. **On this day** shows photos from the same date in earlier years.
|
||||
- **Filters** narrow the timeline to images, videos or RAW files, to favorites, or to photos that
|
||||
are in no album.
|
||||
- **RAW + JPEG pairs** of the same shot show as one tile. The viewer switches between them, and
|
||||
moving the tile to the Trash removes both.
|
||||
- **Albums** lists your albums and albums shared with you. Create, rename and delete albums, and
|
||||
add or remove photos.
|
||||
- **Places** groups photos by the town they were taken in, as cards or on a map. Towns come from
|
||||
bundled [GeoNames](https://www.geonames.org/) data (CC BY 4.0) and the map from
|
||||
[Natural Earth](https://www.naturalearthdata.com/) (public domain), so nothing is fetched online.
|
||||
An optional street map from [OpenFreeMap](https://openfreemap.org/) (© OpenStreetMap
|
||||
contributors) can be enabled under **Preferences → General → Appearance**. It is off by default
|
||||
because the tile server learns which areas you look at.
|
||||
- **Viewer**: favorites, EXIF details, show on map, slideshow (F5).
|
||||
- **Find Duplicates…** in the page menu lists byte-identical copies. The **Similar** switch also
|
||||
finds resized or re-encoded copies and burst shots by comparing thumbnails.
|
||||
- **Upload** adds photos. Its menu holds **Import from Google Photos…**.
|
||||
|
||||
### Importing from Google Photos
|
||||
|
||||
Request a Google Takeout export of Google Photos, then pass every `.zip` of it at once. A photo's
|
||||
metadata often sits in a different part of the export than the photo itself.
|
||||
|
||||
```bash
|
||||
pdfs import-google-photos --dry-run ~/Downloads/takeout-*.zip # see what would happen
|
||||
pdfs import-google-photos --wait ~/Downloads/takeout-*.zip
|
||||
```
|
||||
|
||||
Albums are recreated, capture dates and locations are taken from the metadata, and photos already
|
||||
on the account are skipped, so an interrupted import can be run again. `pdfs import-status` and
|
||||
`pdfs cancel-import` follow or stop an import that runs in the background.
|
||||
|
||||
If photos from an earlier import are filed under the day they were uploaded,
|
||||
`pdfs redate-photos --dry-run` finds those whose file name carries a different date, and
|
||||
`pdfs redate-photos` fixes them. Proton cannot change a capture date in place, so each fix
|
||||
re-uploads the photo and moves the original to the Trash, keeping favorites and albums.
|
||||
|
||||
## Sharing
|
||||
|
||||
- **Share…** on a file or folder invites people by email as viewer or editor, or creates a public
|
||||
link with an optional password and expiry. `pdfs share`, `pdfs members`, `pdfs share-role`,
|
||||
`pdfs unshare` and `pdfs public-link` do the same from a terminal.
|
||||
- **Shared by me** lists everything you share, filtered by links, people or expired links. Copy a
|
||||
live link or stop several shares at once.
|
||||
- **Shared with me** lists what others share with you, with who shared it and your access.
|
||||
Pending invitations wait in a banner; `pdfs invitations` accepts or rejects them. Saved public
|
||||
links (`pdfs bookmarks`) have their own view.
|
||||
|
||||
## Trash and version history
|
||||
|
||||
The **Trash** page lists trashed items with the date they were deleted. Restore puts an item back
|
||||
where it was, together with trashed folders above it. **Delete Forever** and **Empty Trash** are
|
||||
permanent. The CLI equivalents are `pdfs trash`, `pdfs restore`, `pdfs delete-forever` and
|
||||
`pdfs empty-trash`.
|
||||
|
||||
Proton Drive keeps earlier versions of every file. **Details → Versions…** in the app, or
|
||||
`pdfs versions list <path>`, shows them. You can:
|
||||
|
||||
- **Restore** a version. This happens on the server; nothing is re-uploaded.
|
||||
- **Save** a version to a separate local file without touching the current one.
|
||||
- **Delete** an old version permanently. The current version cannot be deleted.
|
||||
|
||||
## Computers
|
||||
|
||||
**Computers** lists this computer and every other computer that backs up to your account.
|
||||
|
||||
- **This computer** shows its synced folders with their state, and **Restore Folders…** brings
|
||||
back folders that are backed up under it but not synced here yet.
|
||||
- **Another computer** can be browsed read-only in the same list or grid as My files, and
|
||||
**Restore to This Computer…** downloads its folders here.
|
||||
- **Sync With This Computer…** makes this computer sync the same folders as another one, for
|
||||
example a desktop and a laptop. Both keep the folders in step: a change or a deletion on one
|
||||
reaches the other. See [Syncing several computers](#syncing-several-computers).
|
||||
- **Continue This Backup Here…** makes this computer take over another computer's backup, for
|
||||
example after reinstalling or replacing a machine. See [RECOVERY.md](RECOVERY.md).
|
||||
- **Move to This Computer…** moves another computer's folders into this computer's backup, for
|
||||
example when you switch from Windows to Linux, and then offers them for syncing here. The other
|
||||
computer stays in the list, without those folders.
|
||||
|
||||
### Syncing several computers
|
||||
|
||||
Two or more computers can sync the same folders through one backup in Proton Drive.
|
||||
|
||||
1. Sync the folders on the first computer as usual.
|
||||
2. On the second computer, open **Computers**, open the menu of the first computer and choose
|
||||
**Sync With This Computer…**. From a terminal: `pdfs devices join UID`, with the UID from
|
||||
`pdfs devices list`.
|
||||
3. Choose which of its folders to sync here and where, as in a restore.
|
||||
|
||||
From then on, both computers sync into that backup. A folder you add later on either computer
|
||||
lands there too, and a folder with the same name joins the one already there. An edit on one
|
||||
computer reaches the other within about two minutes. When both computers change the same file in
|
||||
that time, each keeps the other's version under the original name and its own as a conflict copy
|
||||
named after it. Folders that were synced here before joining stay in this computer's own backup.
|
||||
|
||||
## Search launcher
|
||||
|
||||
`pdfs-prompt` searches Proton Drive and your home folder together, in one ranked list. It
|
||||
tolerates prefixes, abbreviations, typos and swapped letters, and matches on parent folder names
|
||||
as well. Bind it to a keyboard shortcut in your desktop settings.
|
||||
|
||||
The window stays loaded after the first use, so later shortcuts open it instantly. Folders, audio
|
||||
and video open straight from the Proton Drive folder, so players can start before the whole file
|
||||
downloads. Other Drive files download first, then open.
|
||||
|
||||
To make the first shortcut instant as well, start the launcher hidden with your session.
|
||||
`pdfs-prompt --preload` loads the window without showing it and does nothing if the launcher is
|
||||
already running. For Hyprland:
|
||||
|
||||
```ini
|
||||
exec-once = pdfs-prompt --preload
|
||||
bind = SUPER, space, exec, pdfs-prompt
|
||||
```
|
||||
|
||||
### Using your own launcher
|
||||
|
||||
`pdfs-prompt --dmenu` shows the results in fuzzel, rofi, wofi, tofi, bemenu or dmenu, whichever is
|
||||
installed:
|
||||
|
||||
```bash
|
||||
pdfs-prompt --dmenu
|
||||
pdfs-prompt --dmenu --menu 'fuzzel --dmenu --width 60'
|
||||
pdfs-prompt --dmenu --query invoice
|
||||
```
|
||||
|
||||
A dmenu-style launcher filters a fixed list and cannot ask for new results per keystroke, so the
|
||||
search takes two steps. The first list, `Search Drive ›`, shows your available-offline files; type
|
||||
a query and press Enter to search. The second, `Drive: invoice ›`, shows the results; Enter opens
|
||||
one, and typing something else searches again.
|
||||
|
||||
### Search as you type with fzf
|
||||
|
||||
`pdfs-prompt --fzf` re-queries the service on every keystroke, like the built-in window. When
|
||||
started from a keybinding without a terminal, it opens one itself (foot, ghostty, kitty,
|
||||
alacritty, wezterm or xterm) with the app ID `pdfs-prompt`, so one window rule covers all of them.
|
||||
For Hyprland:
|
||||
|
||||
```ini
|
||||
windowrulev2 = float, class:^(pdfs-prompt)$
|
||||
windowrulev2 = size 900 500, class:^(pdfs-prompt)$
|
||||
windowrulev2 = center, class:^(pdfs-prompt)$
|
||||
bind = SUPER, space, exec, pdfs-prompt --fzf
|
||||
```
|
||||
|
||||
To make `--dmenu` or `--fzf` the default without changing your keybinding, set `prompt.mode` in
|
||||
`config.json` ([CONFIGURATION.md](CONFIGURATION.md#prompt)). `--gtk` forces the built-in window.
|
||||
|
||||
## Choosing how files open
|
||||
|
||||
Files open with `xdg-open` by default. The `open_with` section of `config.json` overrides that per
|
||||
file type, for example text in Neovim inside a terminal. The rules apply to the search launcher
|
||||
and to the app. See [CONFIGURATION.md](CONFIGURATION.md#open_with).
|
||||
|
||||
## Tray
|
||||
|
||||
`pdfs-tray` starts at login and shows whether files are up to date, syncing, paused or offline.
|
||||
Its menu opens the app or the Proton Drive folder, pauses or resumes sync, and stops or connects
|
||||
the service. **Hide Tray Icon** removes it until you turn it back on under
|
||||
**Preferences → General → Appearance → Show tray icon**.
|
||||
|
||||
GNOME needs the AppIndicator extension to show tray icons.
|
||||
|
||||
## Preferences
|
||||
|
||||
| Page | Settings |
|
||||
|---|---|
|
||||
| **General** | Start on login · Proton Drive folder location · upload and download limits · Proton theme, street map for Places, tray icon · language |
|
||||
| **General** | Start on login · Proton Drive folder location · upload and download limits · Proton theme, street map for Places, tray icon · language · check for updates |
|
||||
| **Storage** | Cache size limit · clear the cache · files kept available offline |
|
||||
|
||||
The account menu shows storage used and holds **Sign Out…**. Settings not exposed in the app are
|
||||
listed in [CONFIGURATION.md](CONFIGURATION.md).
|
||||
The account menu shows the storage you use and holds **Sign Out…**. Settings that are not in the
|
||||
app are described in [Configuration](CONFIGURATION.md).
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
# Build + install: cd packaging && makepkg -fi
|
||||
|
||||
pkgname=proton-drive-linux
|
||||
pkgver=3.5.0
|
||||
pkgver=3.6.0
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = proton-drive-for-linux-bin
|
||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
||||
pkgver = 3.4.1
|
||||
pkgver = 3.6.0
|
||||
pkgrel = 1
|
||||
url = https://proton-drive.narl.io
|
||||
install = proton-drive.install
|
||||
@@ -22,25 +22,25 @@ pkgbase = proton-drive-for-linux-bin
|
||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||
provides = proton-drive-for-linux=3.4.1
|
||||
provides = proton-drive-linux=3.4.1
|
||||
provides = proton-drive-for-linux=3.6.0
|
||||
provides = proton-drive-linux=3.6.0
|
||||
provides = pdfs
|
||||
conflicts = proton-drive-linux
|
||||
conflicts = proton-drive-for-linux
|
||||
conflicts = proton-drive-for-linux-git
|
||||
options = !strip
|
||||
options = !debug
|
||||
source = proton-drive-linux-3.4.1-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.4.1/proton-drive-linux-3.4.1-x86_64.tar.gz
|
||||
source = io.narl.proton-drive-linux-3.4.1.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.4.1/packaging/io.narl.proton-drive-linux.desktop
|
||||
source = io.narl.proton-drive-linux-tray-3.4.1.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.4.1/packaging/io.narl.proton-drive-linux-tray.desktop
|
||||
source = io.narl.proton-drive-linux-3.4.1.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.4.1/packaging/io.narl.proton-drive-linux.svg
|
||||
source = proton-drive-3.4.1.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.4.1/packaging/proton-drive.service
|
||||
source = LICENSE-3.4.1::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.4.1/LICENSE
|
||||
sha256sums = dc547922859155558b139eacbb5414ef421d6afcbeee254213223dbc39489fdd
|
||||
source = proton-drive-linux-3.6.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.6.0/proton-drive-linux-3.6.0-x86_64.tar.gz
|
||||
source = io.narl.proton-drive-linux-3.6.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/io.narl.proton-drive-linux.desktop
|
||||
source = io.narl.proton-drive-linux-tray-3.6.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/io.narl.proton-drive-linux-tray.desktop
|
||||
source = io.narl.proton-drive-linux-3.6.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/io.narl.proton-drive-linux.svg
|
||||
source = proton-drive-3.6.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/proton-drive.service
|
||||
source = LICENSE-3.6.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/LICENSE
|
||||
sha256sums = 8e9b76b0df238af1c8978c33b4c27c0c548e1faa81b368d0d271ed564d0a76c7
|
||||
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
||||
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
||||
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
||||
sha256sums = 77280bff5f693fbc5ffa9f3ccd06a43cfff96d770290aa8d7d0c28e9dde277be
|
||||
sha256sums = 44c25dca6e759c46fca50b58099aa6194abe78293dee07d971bbbce0ff3ca58f
|
||||
sha256sums = 02c419e7f9af08dda720e4f852f3e57cf5a69e192605440f750e9d244fa2817c
|
||||
|
||||
pkgname = proton-drive-for-linux-bin
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||
|
||||
pkgname=proton-drive-for-linux-bin
|
||||
pkgver=3.4.1
|
||||
pkgver=3.6.0
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
||||
arch=('x86_64')
|
||||
@@ -36,11 +36,11 @@ source=("proton-drive-linux-$pkgver-x86_64.tar.gz::$_repo/releases/download/$_ta
|
||||
"io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
|
||||
"proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service"
|
||||
"LICENSE-$pkgver::$_raw/LICENSE")
|
||||
sha256sums=('dc547922859155558b139eacbb5414ef421d6afcbeee254213223dbc39489fdd'
|
||||
sha256sums=('8e9b76b0df238af1c8978c33b4c27c0c548e1faa81b368d0d271ed564d0a76c7'
|
||||
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
||||
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
||||
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
||||
'77280bff5f693fbc5ffa9f3ccd06a43cfff96d770290aa8d7d0c28e9dde277be'
|
||||
'44c25dca6e759c46fca50b58099aa6194abe78293dee07d971bbbce0ff3ca58f'
|
||||
'02c419e7f9af08dda720e4f852f3e57cf5a69e192605440f750e9d244fa2817c')
|
||||
|
||||
package() {
|
||||
@@ -65,4 +65,7 @@ package() {
|
||||
cp -r --no-preserve=ownership "$srcdir/locale" "$pkgdir/usr/share/locale"
|
||||
|
||||
install -Dm644 "$srcdir/LICENSE-$pkgver" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
|
||||
# pacman brings new releases, so the app never checks for them itself.
|
||||
install -Dm644 /dev/null "$pkgdir/usr/share/proton-drive-linux/managed-updates"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = proton-drive-for-linux-git
|
||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
|
||||
pkgver = 3.4.1.r0.g18cc2ea
|
||||
pkgver = 3.6.0.r0.g2720191
|
||||
pkgrel = 1
|
||||
url = https://proton-drive.narl.io
|
||||
install = proton-drive.install
|
||||
@@ -25,8 +25,8 @@ pkgbase = proton-drive-for-linux-git
|
||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||
provides = proton-drive-for-linux=3.4.1.r0.g18cc2ea
|
||||
provides = proton-drive-linux=3.4.1.r0.g18cc2ea
|
||||
provides = proton-drive-for-linux=3.6.0.r0.g2720191
|
||||
provides = proton-drive-linux=3.6.0.r0.g2720191
|
||||
provides = pdfs
|
||||
conflicts = proton-drive-linux
|
||||
conflicts = proton-drive-for-linux
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||
|
||||
pkgname=proton-drive-for-linux-git
|
||||
pkgver=3.4.1.r0.g18cc2ea
|
||||
pkgver=3.6.0.r0.g2720191
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
||||
arch=('x86_64')
|
||||
@@ -83,4 +83,7 @@ package() {
|
||||
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
|
||||
# pacman brings new releases, so the app never checks for them itself.
|
||||
install -Dm644 /dev/null "$pkgdir/usr/share/proton-drive-linux/managed-updates"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = proton-drive-for-linux
|
||||
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
||||
pkgver = 3.4.1
|
||||
pkgver = 3.6.0
|
||||
pkgrel = 1
|
||||
url = https://proton-drive.narl.io
|
||||
install = proton-drive.install
|
||||
@@ -24,13 +24,13 @@ pkgbase = proton-drive-for-linux
|
||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||
provides = proton-drive-linux=3.4.1
|
||||
provides = proton-drive-linux=3.6.0
|
||||
provides = pdfs
|
||||
conflicts = proton-drive-linux
|
||||
conflicts = proton-drive-for-linux-bin
|
||||
conflicts = proton-drive-for-linux-git
|
||||
options = !lto
|
||||
source = proton-drive-for-linux-3.4.1.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.4.1.tar.gz
|
||||
sha256sums = 7d36f39fa6a3b44f726f543c3a3f3f30e9c01c64bc6cffe24ca4092aff3e5711
|
||||
source = proton-drive-for-linux-3.6.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.6.0.tar.gz
|
||||
sha256sums = d9b16b1027816a379d6a11c6396d779f751a6f94becaa16b17439420f561d13d
|
||||
|
||||
pkgname = proton-drive-for-linux
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||
|
||||
pkgname=proton-drive-for-linux
|
||||
pkgver=3.4.1
|
||||
pkgver=3.6.0
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
||||
arch=('x86_64')
|
||||
@@ -26,7 +26,7 @@ options=('!lto')
|
||||
# Stops the running daemon before removal, so the mount stops mirroring deletes.
|
||||
install=proton-drive.install
|
||||
source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz")
|
||||
sha256sums=('7d36f39fa6a3b44f726f543c3a3f3f30e9c01c64bc6cffe24ca4092aff3e5711')
|
||||
sha256sums=('d9b16b1027816a379d6a11c6396d779f751a6f94becaa16b17439420f561d13d')
|
||||
|
||||
_srcname="proton-drive-linux-$pkgver"
|
||||
|
||||
@@ -78,4 +78,7 @@ package() {
|
||||
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
|
||||
# pacman brings new releases, so the app never checks for them itself.
|
||||
install -Dm644 /dev/null "$pkgdir/usr/share/proton-drive-linux/managed-updates"
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# COPR repository
|
||||
|
||||
Fedora users install from the COPR project
|
||||
[`narl-rs/proton-drive-linux`](https://copr.fedorainfracloud.org/coprs/narl-rs/proton-drive-linux/),
|
||||
so `dnf upgrade` brings every release. COPR builds `packaging/proton-drive-linux.spec` from two
|
||||
release assets:
|
||||
|
||||
| Source | What it is |
|
||||
| --- | --- |
|
||||
| `Source0` | GitHub's archive of the `v<version>` tag |
|
||||
| `Source1` | `proton-drive-linux-vendor-<version>.tar.xz`: every crate from `Cargo.lock`, made by `cargo vendor` in `release.yml` |
|
||||
|
||||
With the vendored crates the build needs no network, so the COPR project keeps network access
|
||||
off.
|
||||
|
||||
## Releasing a new version
|
||||
|
||||
`.github/workflows/copr.yml` does this automatically:
|
||||
|
||||
- **On a release tag**, `release.yml` calls it after the GitHub release is published. It writes
|
||||
the version into the spec, downloads both sources with `spectool`, builds a source package
|
||||
and submits it with `copr-cli build`. The job waits for COPR, so a failed build fails the
|
||||
release workflow.
|
||||
- **By hand**, run the workflow from the Actions tab with a version. The release must already
|
||||
have its vendor tarball.
|
||||
|
||||
It needs the `COPR_CONFIG` repository secret: the whole content of `~/.config/copr` from
|
||||
<https://copr.fedorainfracloud.org/api/>. **The token expires after 180 days.** Renew it on that
|
||||
page and replace the secret, or the next release's COPR job fails.
|
||||
|
||||
To do the same by hand:
|
||||
|
||||
```bash
|
||||
spectool -g -C ~/rpmbuild/SOURCES packaging/proton-drive-linux.spec
|
||||
rpmbuild -bs packaging/proton-drive-linux.spec
|
||||
copr-cli build narl-rs/proton-drive-linux ~/rpmbuild/SRPMS/proton-drive-linux-*.src.rpm
|
||||
```
|
||||
|
||||
## Project settings
|
||||
|
||||
- Chroots: `fedora-44-x86_64`, `fedora-45-x86_64` and `fedora-rawhide-x86_64`. The spec is
|
||||
`ExclusiveArch: x86_64` and needs `rust >= 1.96`.
|
||||
- Network access in builds: off.
|
||||
@@ -106,6 +106,9 @@ rustPlatform.buildRustPackage {
|
||||
|
||||
patchShebangs po/build.sh
|
||||
po/build.sh $out/share/locale
|
||||
|
||||
# Nix brings new releases, so the app never checks for them itself.
|
||||
install -Dm644 /dev/null $out/share/proton-drive-linux/managed-updates
|
||||
'';
|
||||
|
||||
# Thumbnails shell out to exiftool, and to ffmpeg for video. Suffixed, so a
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
# Local in-tree RPM (mirrors packaging/PKGBUILD).
|
||||
# From the repository root:
|
||||
# rpmbuild -bb packaging/proton-drive-linux.spec --define "git_dir $PWD" ...
|
||||
# %build needs network so cargo can fetch crates.io (same trade-off as the Arch PKGBUILD).
|
||||
# Two ways to build this spec:
|
||||
#
|
||||
# - From a checkout (local builds and the release workflow). From the repository root:
|
||||
# rpmbuild -bb packaging/proton-drive-linux.spec --define "git_dir $PWD" ...
|
||||
# %build needs network so cargo can fetch crates.io (same trade-off as the Arch PKGBUILD).
|
||||
# - From the release tarballs (COPR, .github/workflows/copr.yml), when git_dir is not
|
||||
# defined. Source1 holds every crate, so the build runs offline.
|
||||
|
||||
%global debug_package %{nil}
|
||||
# Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice.
|
||||
%global _lto_cflags %{nil}
|
||||
|
||||
%{!?version: %global version 0.4.0}
|
||||
%{!?version: %global version 3.6.0}
|
||||
%global forge https://github.com/narrrl/proton-drive-linux
|
||||
# Files come from the checkout, or from the unpacked tag archive.
|
||||
%global src %{?git_dir}%{!?git_dir:.}
|
||||
|
||||
Name: proton-drive-linux
|
||||
Version: %{version}
|
||||
@@ -16,9 +22,13 @@ Summary: Unofficial Proton Drive client for Linux (FUSE, CLI, GTK4 app +
|
||||
License: MIT
|
||||
URL: https://proton-drive.narl.io
|
||||
ExclusiveArch: x86_64
|
||||
%if 0%{!?git_dir:1}
|
||||
Source0: %{forge}/archive/refs/tags/v%{version}.tar.gz#/%{name}-%{version}.tar.gz
|
||||
Source1: %{forge}/releases/download/v%{version}/%{name}-vendor-%{version}.tar.xz
|
||||
%endif
|
||||
|
||||
BuildRequires: cargo
|
||||
BuildRequires: rust
|
||||
BuildRequires: rust >= 1.96
|
||||
BuildRequires: pkgconf-pkg-config
|
||||
BuildRequires: fuse3-devel
|
||||
BuildRequires: gtk4-devel
|
||||
@@ -57,18 +67,31 @@ block-level caching, a scriptable CLI, and a GTK4/Libadwaita desktop app with
|
||||
system tray and search launcher. Unofficial and not affiliated with Proton AG.
|
||||
|
||||
%prep
|
||||
# In-tree build: no Source tarball. Pass --define "git_dir /path/to/checkout".
|
||||
test -n "%{?git_dir}" || (echo 'Pass --define "git_dir $PWD" from the repo root' >&2; exit 1)
|
||||
%if 0%{?git_dir:1}
|
||||
# In-tree build: no Source tarball.
|
||||
test -f %{git_dir}/Cargo.toml
|
||||
cp -a %{git_dir}/LICENSE .
|
||||
%else
|
||||
# -a 1 unpacks the vendored crates into vendor/ inside the source tree.
|
||||
%autosetup -n %{name}-%{version} -a 1
|
||||
mkdir -p .cargo
|
||||
cat > .cargo/config.toml <<'EOF'
|
||||
[source.crates-io]
|
||||
replace-with = "vendored-sources"
|
||||
|
||||
[source.vendored-sources]
|
||||
directory = "vendor"
|
||||
EOF
|
||||
%endif
|
||||
|
||||
%build
|
||||
# The release workflow has already built target/release and passes --define "prebuilt 1".
|
||||
# rpmbuild exports its own compiler flags, which would make cargo rebuild everything.
|
||||
%if 0%{?prebuilt}
|
||||
test -x %{git_dir}/target/release/pdfs
|
||||
%else
|
||||
cd %{git_dir}
|
||||
cargo build --release --locked \
|
||||
cd %{src}
|
||||
cargo build --release %{?git_dir:--locked}%{!?git_dir:--frozen} \
|
||||
--bin pdfs \
|
||||
--bin pdfs-tray \
|
||||
--bin pdfs-app \
|
||||
@@ -76,21 +99,25 @@ cargo build --release --locked \
|
||||
%endif
|
||||
|
||||
%install
|
||||
rel=%{git_dir}/target/release
|
||||
rel=%{src}/target/release
|
||||
install -D -m0755 "$rel/pdfs" %{buildroot}%{_bindir}/pdfs
|
||||
install -D -m0755 "$rel/pdfs-tray" %{buildroot}%{_bindir}/pdfs-tray
|
||||
install -D -m0755 "$rel/pdfs-app" %{buildroot}%{_bindir}/pdfs-app
|
||||
install -D -m0755 "$rel/pdfs-prompt" %{buildroot}%{_bindir}/pdfs-prompt
|
||||
|
||||
install -D -m0644 %{git_dir}/packaging/io.narl.proton-drive-linux.desktop \
|
||||
install -D -m0644 %{src}/packaging/io.narl.proton-drive-linux.desktop \
|
||||
%{buildroot}%{_datadir}/applications/io.narl.proton-drive-linux.desktop
|
||||
install -D -m0644 %{git_dir}/packaging/io.narl.proton-drive-linux-tray.desktop \
|
||||
install -D -m0644 %{src}/packaging/io.narl.proton-drive-linux-tray.desktop \
|
||||
%{buildroot}%{_sysconfdir}/xdg/autostart/io.narl.proton-drive-linux-tray.desktop
|
||||
install -D -m0644 %{git_dir}/packaging/io.narl.proton-drive-linux.svg \
|
||||
install -D -m0644 %{src}/packaging/io.narl.proton-drive-linux.svg \
|
||||
%{buildroot}%{_datadir}/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg
|
||||
install -D -m0644 %{git_dir}/packaging/proton-drive.service \
|
||||
install -D -m0644 %{src}/packaging/proton-drive.service \
|
||||
%{buildroot}/usr/lib/systemd/user/proton-drive.service
|
||||
%{git_dir}/po/build.sh %{buildroot}%{_datadir}/locale
|
||||
%{src}/po/build.sh %{buildroot}%{_datadir}/locale
|
||||
%if 0%{!?git_dir:1}
|
||||
# dnf brings new releases from COPR, so the app does not offer its own check.
|
||||
install -D -m0644 /dev/null %{buildroot}%{_datadir}/proton-drive-linux/managed-updates
|
||||
%endif
|
||||
|
||||
# Removing the package does not stop a running daemon: it keeps serving the
|
||||
# mount from the deleted binary, so files deleted under the mount afterwards,
|
||||
@@ -122,6 +149,9 @@ fi
|
||||
%{_datadir}/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg
|
||||
/usr/lib/systemd/user/proton-drive.service
|
||||
%{_datadir}/locale/*/LC_MESSAGES/pdfs.mo
|
||||
%if 0%{!?git_dir:1}
|
||||
%{_datadir}/proton-drive-linux/managed-updates
|
||||
%endif
|
||||
|
||||
%changelog
|
||||
* Sun Jul 19 2026 Local Packager - 0.4.0-1
|
||||
|
||||
@@ -19,6 +19,7 @@ crates/pdfs-gui/src/app/pages/status.rs
|
||||
crates/pdfs-gui/src/app/pages/takeout.rs
|
||||
crates/pdfs-gui/src/app/pages/trash.rs
|
||||
crates/pdfs-gui/src/app/pages/verify.rs
|
||||
crates/pdfs-gui/src/app/update.rs
|
||||
crates/pdfs-gui/src/app/widgets/compat.rs
|
||||
crates/pdfs-gui/src/app/widgets/details.rs
|
||||
crates/pdfs-gui/src/app/widgets/file_list.rs
|
||||
|
||||
+736
-542
File diff suppressed because it is too large.
Load diff
+738
-550
File diff suppressed because it is too large.
Load diff
+739
-543
File diff suppressed because it is too large.
Load diff
+506
-9
@@ -566,6 +566,357 @@ def test_mmap_and_copy_paths(ctx: Context) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _copy_range(ctx: Context, key: str, src: int, dst: int, count: int, offset_src: int, offset_dst: int) -> int | None:
|
||||
"""`copy_file_range` until `count` bytes moved or the source ran out.
|
||||
|
||||
Returns how many bytes moved, or None when the first call was refused
|
||||
cleanly. The mount copies at most 16 MiB a call, and `cp` loops the same
|
||||
way, so a large copy reaches it as several calls with growing offsets.
|
||||
"""
|
||||
moved = 0
|
||||
first = ctx.probe(
|
||||
key,
|
||||
lambda: os.copy_file_range(src, dst, count, offset_src, offset_dst),
|
||||
allow=CLEAN_UNSUPPORTED | {errno.EXDEV},
|
||||
)
|
||||
if not first.ok:
|
||||
return None
|
||||
step = first.value
|
||||
calls = 1
|
||||
while step and moved + step < count:
|
||||
moved += step
|
||||
step = os.copy_file_range(src, dst, count - moved, offset_src + moved, offset_dst + moved)
|
||||
calls += 1
|
||||
ctx.note(f"{key}.calls", calls)
|
||||
return moved + step
|
||||
|
||||
|
||||
def test_copy_file_range_ranges(ctx: Context) -> None:
|
||||
"""`copy_file_range` beyond the first call, at offsets, and from an open file.
|
||||
|
||||
The mount answers the call from bytes this machine already has: the write
|
||||
handle's scratch, a pending upload, or the cache. Each answer is a write
|
||||
into the destination, so it has to land exactly where the offsets say,
|
||||
fill a gap past the end with zeros, and survive the upload.
|
||||
"""
|
||||
root = ctx.root
|
||||
big = pattern(17 * 1024 * 1024 + 4099, "cfr-big")
|
||||
source = root / "cfr-source.bin"
|
||||
write_durable(source, big)
|
||||
|
||||
def copy(key: str, src_path: Path, dst_path: Path, count: int, offset_src: int = 0, offset_dst: int = 0) -> int | None:
|
||||
src = os.open(src_path, os.O_RDONLY)
|
||||
dst = os.open(dst_path, os.O_CREAT | os.O_WRONLY, 0o600)
|
||||
try:
|
||||
moved = _copy_range(ctx, key, src, dst, count, offset_src, offset_dst)
|
||||
os.fsync(dst)
|
||||
finally:
|
||||
os.close(src)
|
||||
os.close(dst)
|
||||
return moved
|
||||
|
||||
expected: dict[Path, bytes] = {}
|
||||
|
||||
whole = root / "cfr-whole.bin"
|
||||
moved = copy("whole", source, whole, len(big))
|
||||
if moved is not None:
|
||||
check(moved == len(big), f"whole: copy_file_range moved {moved} bytes, expected {len(big)}")
|
||||
expected[whole] = big
|
||||
|
||||
# Into the middle of a file that exists, and past the end of another.
|
||||
middle = root / "cfr-middle.bin"
|
||||
base = pattern(300_000, "cfr-base")
|
||||
write_durable(middle, base)
|
||||
moved = copy("middle", source, middle, 100_000, offset_src=5_000_000, offset_dst=123_457)
|
||||
if moved is not None:
|
||||
check(moved == 100_000, f"middle: copy_file_range moved {moved} bytes, expected {100_000}")
|
||||
expected[middle] = base[:123_457] + big[5_000_000:5_100_000] + base[223_457:]
|
||||
gap = root / "cfr-gap.bin"
|
||||
write_durable(gap, b"head")
|
||||
moved = copy("gap", source, gap, 70_001, offset_src=1, offset_dst=200_000)
|
||||
if moved is not None:
|
||||
check(moved == 70_001, f"gap: copy_file_range moved {moved} bytes, expected {70_001}")
|
||||
expected[gap] = b"head" + bytes(200_000 - 4) + big[1:70_002]
|
||||
|
||||
# Past the source's end the copy is short, and at it nothing moves.
|
||||
tail = root / "cfr-tail.bin"
|
||||
moved = copy("tail", source, tail, 10_000, offset_src=len(big) - 99)
|
||||
if moved is not None:
|
||||
check(moved == 99, f"tail: copy_file_range moved {moved} bytes, expected {99}")
|
||||
expected[tail] = big[-99:]
|
||||
|
||||
# From a file whose last write has not been released yet.
|
||||
unsaved = root / "cfr-unsaved.bin"
|
||||
fresh = pattern(600_001, "cfr-unsaved")
|
||||
writer = os.open(unsaved, os.O_CREAT | os.O_RDWR | os.O_TRUNC, 0o600)
|
||||
try:
|
||||
write_all(writer, fresh)
|
||||
moved = copy("unsaved", unsaved, root / "cfr-from-unsaved.bin", len(fresh))
|
||||
finally:
|
||||
os.close(writer)
|
||||
if moved is not None:
|
||||
check(moved == len(fresh), f"unsaved: copy_file_range moved {moved} bytes, expected {len(fresh)}")
|
||||
expected[root / "cfr-from-unsaved.bin"] = fresh
|
||||
|
||||
def verify(when: str) -> None:
|
||||
for path, data in expected.items():
|
||||
size = os.lstat(path).st_size
|
||||
check(size == len(data), f"{path.name} {when}: {size} bytes, expected {len(data)}")
|
||||
check_bytes(read(path), data, f"{path.name} {when}")
|
||||
|
||||
verify("right after the copy")
|
||||
if not ctx.is_live or ctx.daemon is None:
|
||||
return
|
||||
ctx.daemon.wait_for_queue()
|
||||
verify("after the upload")
|
||||
# A server-side copy has a new uid, so nothing of it is cached: reading
|
||||
# it reads what Drive holds.
|
||||
if _my_files_root(ctx) is None:
|
||||
return
|
||||
landed = root / "cfr-landed"
|
||||
landed.mkdir()
|
||||
ctx.daemon.wait_for_queue()
|
||||
for path in expected:
|
||||
_pdfs_copy(ctx.daemon, path, landed)
|
||||
for path, data in expected.items():
|
||||
check_bytes(read(landed / path.name), data, f"{path.name} as Drive holds it")
|
||||
|
||||
|
||||
def _my_files_root(ctx: Context) -> Path | None:
|
||||
"""The My files mount the case runs in, or None anywhere else.
|
||||
|
||||
`pdfs copy`, `pdfs du` and public links address a path inside My files;
|
||||
synced folders have no path of their own there.
|
||||
"""
|
||||
if ctx.daemon is None:
|
||||
return None
|
||||
mountpoint = (ctx.daemon.status().get("mount") or {}).get("mountpoint")
|
||||
if not mountpoint:
|
||||
return None
|
||||
mountpoint = Path(mountpoint).resolve()
|
||||
return mountpoint if mountpoint in ctx.root.resolve().parents else None
|
||||
|
||||
|
||||
def _require_my_files(ctx: Context) -> Daemon:
|
||||
daemon = ctx.require_daemon()
|
||||
if _my_files_root(ctx) is None:
|
||||
raise Skip("needs a root inside My files")
|
||||
return daemon
|
||||
|
||||
|
||||
COPIED_TO = re.compile(r"^copied to (.+)$")
|
||||
|
||||
|
||||
def _pdfs_copy(daemon: Daemon, source: Path, dest: Path) -> str:
|
||||
"""`pdfs copy`, returning the name the copy got."""
|
||||
message = daemon.command("copy", str(source), str(dest)).strip()
|
||||
match = COPIED_TO.match(message)
|
||||
check(match is not None, f"pdfs copy {source.name}: unexpected reply {message!r}")
|
||||
return match[1]
|
||||
|
||||
|
||||
def _pdfs_copy_refused(daemon: Daemon, source: Path, dest: Path) -> str | None:
|
||||
"""`pdfs copy` expected to be refused for changes Drive lacks: the refusal,
|
||||
or None if the copy went ahead (and returned its name in the log)."""
|
||||
try:
|
||||
daemon.command("copy", str(source), str(dest))
|
||||
except RuntimeError as error:
|
||||
detail = str(error)
|
||||
check("have not reached Proton Drive" in detail, f"pdfs copy {source.name}: unexpected refusal: {detail}")
|
||||
return detail
|
||||
return None
|
||||
|
||||
|
||||
def test_server_copy(ctx: Context) -> None:
|
||||
"""`pdfs copy` makes an exact copy on Drive and uploads nothing.
|
||||
|
||||
A tree and a lone file are copied; the file into its own folder, where
|
||||
its name is taken and the copy gets the next free one. The copy is read
|
||||
back as listed and again after its listing is dropped, and nothing of it
|
||||
may wait in the queue: Drive made it, so there is nothing to send.
|
||||
"""
|
||||
daemon = _require_my_files(ctx)
|
||||
root = ctx.root
|
||||
name = f"tree-{uuid.uuid4().hex[:8]}"
|
||||
files = _move_tree(root, name)
|
||||
dest = root / "copies"
|
||||
dest.mkdir()
|
||||
daemon.wait_for_queue()
|
||||
|
||||
check(_pdfs_copy(daemon, root / name, dest) == name, f"the copy of {name} was renamed")
|
||||
note = root / name / "note.txt"
|
||||
twin = _pdfs_copy(daemon, note, note.parent)
|
||||
check(twin != note.name, f"the copy of {note.name} beside it kept the name {twin!r}")
|
||||
check((note.parent / twin).is_file(), f"{twin} is not listed beside {note.name}")
|
||||
queued = [
|
||||
item
|
||||
for item in daemon.queue()
|
||||
if root.name in (parts := Path(item.get("path", "")).parts) and ("copies" in parts or twin in parts)
|
||||
]
|
||||
check(not queued, f"a server copy queued work: {_describe_uploads(queued)}")
|
||||
|
||||
def verify(when: str) -> None:
|
||||
for relative, data in files.items():
|
||||
size = os.lstat(dest / relative).st_size
|
||||
check(size == len(data), f"{relative} {when}: {size} bytes, expected {len(data)}")
|
||||
check_bytes(read(dest / relative), data, f"{relative} {when}")
|
||||
check((dest / name / "empty-dir").is_dir(), f"the empty folder is missing {when}")
|
||||
check_bytes(read(note.parent / twin), files[Path(name) / "note.txt"], f"{twin} {when}")
|
||||
|
||||
verify("right after the copy")
|
||||
folders = sorted({dest / relative.parent for relative in files} | {dest / name, note.parent})
|
||||
for folder in folders:
|
||||
daemon.command("refresh", str(folder))
|
||||
verify("after dropping the cached listing")
|
||||
_unchanged_at(root, files, "after copying it")
|
||||
|
||||
|
||||
def _unchanged_at(root: Path, files: dict[Path, bytes], what: str) -> None:
|
||||
for relative, data in files.items():
|
||||
check_bytes(read(root / relative), data, f"{relative} {what}")
|
||||
|
||||
|
||||
def test_server_copy_never_copies_an_old_version(ctx: Context) -> None:
|
||||
"""`pdfs copy` of a file being written is refused, and never copies the
|
||||
version before the last save (B256).
|
||||
|
||||
Drive copies what it holds. A file open with unsaved writes, or saved but
|
||||
not yet uploaded, holds more here than there: the copy has to wait.
|
||||
"""
|
||||
daemon = _require_my_files(ctx)
|
||||
root = ctx.root
|
||||
path = root / "being-written.txt"
|
||||
write_durable(path, b"first version")
|
||||
# Its own folder: every case of a run shares the root, and "pdfs copy"
|
||||
# already made "copies" there.
|
||||
dest = root / "b256-copies"
|
||||
dest.mkdir()
|
||||
daemon.wait_for_queue()
|
||||
|
||||
fd = os.open(path, os.O_WRONLY | os.O_TRUNC)
|
||||
try:
|
||||
write_all(fd, b"second version, still open")
|
||||
check(
|
||||
_pdfs_copy_refused(daemon, path, dest) is not None,
|
||||
"pdfs copy went ahead while the file had unsaved writes",
|
||||
)
|
||||
check(not (dest / path.name).exists(), "a refused copy left a file behind")
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
# Right after the close: refused while the upload is owed, then the new
|
||||
# bytes. Never the first version.
|
||||
saved = b"third version, saved"
|
||||
write_durable(path, saved)
|
||||
deadline = time.monotonic() + daemon.timeout
|
||||
refusals = 0
|
||||
while _pdfs_copy_refused(daemon, path, dest) is not None:
|
||||
refusals += 1
|
||||
check(time.monotonic() < deadline, f"pdfs copy was still refused after {daemon.timeout}s")
|
||||
time.sleep(1)
|
||||
ctx.note("refusals before the upload landed", refusals)
|
||||
check_bytes(read(dest / path.name), saved, "the copy made right after a save")
|
||||
|
||||
|
||||
def test_folder_size(ctx: Context) -> None:
|
||||
"""`pdfs du` counts exactly what is below a folder, trash included.
|
||||
|
||||
Drive adds the size up from encrypted bytes, so it is at least the sum of
|
||||
the files. A file moved to the trash still counts, as `pdfs du` says.
|
||||
"""
|
||||
daemon = _require_my_files(ctx)
|
||||
root = ctx.root
|
||||
name = f"sized-{uuid.uuid4().hex[:8]}"
|
||||
files = _move_tree(root, name)
|
||||
folder = root / name
|
||||
items = sum(1 for _ in folder.rglob("*"))
|
||||
daemon.wait_for_queue()
|
||||
|
||||
def size() -> dict:
|
||||
return json.loads(daemon.command("du", str(folder), json_output=True))
|
||||
|
||||
# Drive may add a new tree up a moment after it lands.
|
||||
deadline = time.monotonic() + daemon.timeout
|
||||
while (answer := size())["items"] != items and time.monotonic() < deadline:
|
||||
time.sleep(2)
|
||||
check(answer["items"] == items, f"pdfs du counts {answer['items']} items, expected {items}")
|
||||
total = sum(len(data) for data in files.values())
|
||||
check(answer["size"] >= total, f"pdfs du reports {answer['size']} bytes for {total} bytes of files")
|
||||
ctx.info(f"{answer['size']} bytes on Drive for {total} bytes in {items} items")
|
||||
|
||||
os.unlink(folder / "note.txt")
|
||||
daemon.wait_for_queue()
|
||||
check(size()["items"] == items, "a file moved to the trash stopped counting in pdfs du")
|
||||
|
||||
|
||||
def _public_link(daemon: Daemon, path: Path) -> dict | None:
|
||||
return json.loads(daemon.command("members", str(path), json_output=True)).get("link")
|
||||
|
||||
|
||||
def _link_token(url: str | None) -> str:
|
||||
"""The part of a public link's URL that names the link, without the
|
||||
password fragment."""
|
||||
check(bool(url), "the link has no URL")
|
||||
return url.split("#", 1)[0]
|
||||
|
||||
|
||||
def test_public_link_update(ctx: Context) -> None:
|
||||
"""`pdfs public-link update` changes a link and keeps its URL.
|
||||
|
||||
Each option changed alone leaves the others as they were: a role change
|
||||
keeps the password, removing the password keeps the expiry. A link id
|
||||
that is not the folder's link is refused.
|
||||
"""
|
||||
daemon = _require_my_files(ctx)
|
||||
folder = ctx.root / "linked"
|
||||
folder.mkdir()
|
||||
write_durable(folder / "inside.txt", b"behind a public link")
|
||||
daemon.wait_for_queue()
|
||||
|
||||
password = f"pw-{uuid.uuid4().hex[:12]}"
|
||||
created = json.loads(
|
||||
daemon.command("public-link", "create", str(folder), "--password", password, json_output=True)
|
||||
)["link"]
|
||||
link_id, token = created["id"], _link_token(created["url"])
|
||||
try:
|
||||
check(created["has_password"], "the new link has no password")
|
||||
|
||||
def update(*args: str) -> dict:
|
||||
link = json.loads(
|
||||
daemon.command("public-link", "update", str(folder), link_id, *args, json_output=True)
|
||||
)["link"]
|
||||
check(link["id"] == link_id, f"the update replaced the link: {link['id']} for {link_id}")
|
||||
if link.get("url"):
|
||||
check(_link_token(link["url"]) == token, "the update changed the link's URL")
|
||||
listed = _public_link(daemon, folder)
|
||||
check(listed is not None and listed["id"] == link_id, f"the link is gone after the update: {listed}")
|
||||
return listed
|
||||
|
||||
listed = update("--role", "editor")
|
||||
check(listed["role"] == "editor", f"the role is {listed['role']} after changing it to editor")
|
||||
check(listed["has_password"], "changing the role removed the password")
|
||||
|
||||
expires = int(time.time()) + 7 * 24 * 3600
|
||||
listed = update("--no-password", "--expires", str(expires))
|
||||
check(not listed["has_password"], "--no-password kept the password")
|
||||
check(listed["expires"] == expires, f"the expiry is {listed['expires']}, expected {expires}")
|
||||
check(listed["role"] == "editor", "removing the password changed the role")
|
||||
|
||||
listed = update("--no-expiry")
|
||||
check(listed["expires"] is None, f"--no-expiry kept the expiry {listed['expires']}")
|
||||
|
||||
try:
|
||||
daemon.command("public-link", "update", str(folder), f"not-{link_id}", "--role", "viewer")
|
||||
except RuntimeError as error:
|
||||
check("not found" in str(error), f"a wrong link id was refused for another reason: {error}")
|
||||
else:
|
||||
check(False, "an update with a wrong link id went ahead")
|
||||
check(_public_link(daemon, folder)["role"] == "editor", "a refused update changed the link")
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
daemon.command("public-link", "remove", str(folder), link_id)
|
||||
|
||||
|
||||
def test_namespace_and_errors(ctx: Context) -> None:
|
||||
root = ctx.root
|
||||
a, b = root / "dir-a", root / "dir-b"
|
||||
@@ -2625,6 +2976,7 @@ TESTS = [
|
||||
Case("positioned and vectored I/O", test_positioned_and_vectored_io),
|
||||
Case("truncate, growth, sparse ranges, and EOF", test_resize_and_sparse_io),
|
||||
Case("mmap and zero-copy copy paths", test_mmap_and_copy_paths),
|
||||
Case("copy_file_range across calls, at offsets, and from an open file", test_copy_file_range_ranges),
|
||||
Case("namespace operations and POSIX errors", test_namespace_and_errors),
|
||||
Case("open-handle lifetime across unlink and rename", test_open_lifetime),
|
||||
Case("names, lookup, stat, and enumeration", test_names_and_enumeration),
|
||||
@@ -2670,6 +3022,10 @@ TESTS = [
|
||||
Case("regression B116: trashing a folder drops the creates queued in its subfolders", test_regression_b116_trashed_folder_drops_deeper_creates, (LIVE,)),
|
||||
Case("regression B117: a moved file's queued upload keeps its path", test_regression_b117_moved_upload_keeps_its_path, (LIVE,)),
|
||||
Case("regression B119: an uploaded file stays cached", test_regression_b119_uploaded_file_stays_cached, (LIVE,)),
|
||||
Case("pdfs copy: a copy on Drive reads back exactly and uploads nothing", test_server_copy, (LIVE,)),
|
||||
Case("regression B256: pdfs copy never copies the version before a save", test_server_copy_never_copies_an_old_version, (LIVE,)),
|
||||
Case("pdfs du counts what is below a folder, trash included", test_folder_size, (LIVE,)),
|
||||
Case("pdfs public-link update keeps the URL and the options not changed", test_public_link_update, (LIVE,)),
|
||||
Case("durability across a daemon restart", test_durability_across_restart, (LIVE,)),
|
||||
]
|
||||
|
||||
@@ -2942,7 +3298,7 @@ def select_cases(kind: str, pool: list[Case] | None = None) -> list[Case]:
|
||||
# typo worth failing on.
|
||||
for needle in needles:
|
||||
check(
|
||||
any(needle in case.name.lower() for case in TESTS + MOVE_CASES),
|
||||
any(needle in case.name.lower() for case in TESTS + MOVE_CASES + RECOVERY_CASES),
|
||||
f"PDFS_ACCEPTANCE_ONLY: {needle!r} matched no tests",
|
||||
)
|
||||
return [case for case in cases if any(needle in case.name.lower() for needle in needles)]
|
||||
@@ -3658,30 +4014,36 @@ class Janitor:
|
||||
return [
|
||||
f"{name} ({entry['uid']}) is still in the trash"
|
||||
for entry in matches
|
||||
if not self.purge(entry["uid"], name)
|
||||
if not self.purge(entry["uid"], name, listed=True)
|
||||
]
|
||||
|
||||
def purge(self, uid: str, name: str) -> bool:
|
||||
def purge(self, uid: str, name: str, listed: bool = False) -> bool:
|
||||
"""Permanently delete one trashed node the run owns, by uid.
|
||||
|
||||
By uid and never by name: a name can match something of the user's, a
|
||||
uid cannot. The node has to show up in the trash first, which is also
|
||||
what proves the trash step before it worked, and it has to be gone from
|
||||
a fresh listing afterwards.
|
||||
a fresh listing afterwards. `listed` says the caller just found it in
|
||||
the trash.
|
||||
|
||||
The server's own answer comes first, though: it only deletes a node
|
||||
that is in the trash, and it answers per uid. A listing is a whole-trash
|
||||
enumeration, which on a large trash can time out on every attempt and
|
||||
would otherwise keep a folder the server would delete on request.
|
||||
"""
|
||||
with contextlib.suppress(Exception):
|
||||
last_error = ""
|
||||
try:
|
||||
reply = self.daemon.command("delete-forever", uid, stdin="yes\n")
|
||||
if re.search(r"\bpermanently deleted 1 item", reply):
|
||||
print(f"[cleanup] permanently deleted {name}")
|
||||
return True
|
||||
except Exception as error: # noqa: BLE001 - retried, then reported
|
||||
last_error = f": {error}"
|
||||
deadline = time.monotonic() + self.daemon.timeout
|
||||
attempted = False
|
||||
last_error = ""
|
||||
# A node seen in the trash before that delete and gone after it was
|
||||
# deleted by it, whatever it answered. Counting it as never sent kept
|
||||
# waiting for a node that was already gone, and failed the run.
|
||||
attempted = listed
|
||||
while True:
|
||||
try:
|
||||
if self.daemon.trashed(uid):
|
||||
@@ -3793,7 +4155,7 @@ class Janitor:
|
||||
for entry in trashed:
|
||||
if STALE_ROOT.match(entry["name"]) and entry["name"] not in live_names:
|
||||
print(f"[reap] permanently deleting an abandoned root from the trash: {entry['name']}")
|
||||
if not orphan.purge(entry["uid"], entry["name"]):
|
||||
if not orphan.purge(entry["uid"], entry["name"], listed=True):
|
||||
leftovers.append(f"{entry['name']} ({entry['uid']}) is still in the trash")
|
||||
return leftovers
|
||||
|
||||
@@ -3894,7 +4256,7 @@ class ManagedSyncPair:
|
||||
raise RuntimeError(
|
||||
f"cannot find {self.pdfs!r}; set PDFS_ACCEPTANCE_PDFS to the CLI binary"
|
||||
)
|
||||
if self.paths[0] == self.paths[1]:
|
||||
if len(set(self.paths)) != len(self.paths):
|
||||
raise ValueError("managed live paths must be different directories")
|
||||
deadline = time.monotonic() + self.timeout
|
||||
last_error = None
|
||||
@@ -4537,6 +4899,138 @@ def run_move_contract(
|
||||
return run
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Getting a synced folder unstuck
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RecoveryContext:
|
||||
"""One mirror folder of the run's own, for cases that reset or empty it.
|
||||
|
||||
The mode matrix needs its folders' sentinels intact, so these cases get a
|
||||
folder nobody else checks.
|
||||
"""
|
||||
|
||||
def __init__(self, pair: ManagedSyncPair, daemon: Daemon) -> None:
|
||||
self.pair = pair
|
||||
self.daemon = daemon
|
||||
self.root = pair.paths[0]
|
||||
self.id = pair.ids[self.root]
|
||||
self.infos: list[str] = []
|
||||
|
||||
def folder(self) -> dict:
|
||||
return next(item for item in self.pair.folders() if Path(item["local_path"]).resolve() == self.root)
|
||||
|
||||
def tree(self) -> tuple[str, dict[Path, bytes]]:
|
||||
"""A fresh tree in the folder, with one file named after it, so that a
|
||||
trash entry of it cannot be the user's."""
|
||||
name = f"kept-{uuid.uuid4().hex[:8]}"
|
||||
files = _move_tree(self.root, name)
|
||||
files[Path(name) / f"{name}.txt"] = b"named after its tree " + name.encode()
|
||||
write_durable(self.root / name / f"{name}.txt", files[Path(name) / f"{name}.txt"])
|
||||
return name, files
|
||||
|
||||
def trashed(self, names: set[str]) -> list[str]:
|
||||
self.daemon.command("refresh", "trash")
|
||||
return sorted(entry["name"] for entry in self.daemon.trash() if entry["name"] in names)
|
||||
|
||||
|
||||
def test_sync_reset_changes_nothing(rc: RecoveryContext) -> None:
|
||||
"""Sync From Scratch compares the folder with Drive again and changes
|
||||
nothing that already matched: no deletes, no conflict copies.
|
||||
|
||||
`pdfs sync reset` forgets the folder's record of what it synced. The next
|
||||
pass then sees every file on both sides with no history, which is the
|
||||
case that must copy what is missing and leave matching files alone.
|
||||
"""
|
||||
name, files = rc.tree()
|
||||
rc.pair.force_sync(rc.root)
|
||||
rc.daemon.command("sync", "reset", str(rc.id))
|
||||
# Added after the reset, so the pass after it has to upload it.
|
||||
later = Path(name) / "added-after-the-reset.txt"
|
||||
files[later] = b"only here when the pass starts " + name.encode()
|
||||
write_durable(rc.root / later, files[later])
|
||||
rc.pair.force_sync(rc.root)
|
||||
rc.pair.force_sync(rc.root)
|
||||
|
||||
_unchanged_at(rc.root, files, "after Sync From Scratch")
|
||||
copies = [str(path) for path in rc.root.rglob("*") if "(sync-conflict" in path.name]
|
||||
check(not copies, f"Sync From Scratch made conflict copies: {copies}")
|
||||
trashed = rc.trashed({name, f"{name}.txt"})
|
||||
check(not trashed, f"Sync From Scratch trashed {trashed}")
|
||||
state = rc.folder()
|
||||
check(state["state"] == "idle", f"the folder is {state['state']} after Sync From Scratch")
|
||||
|
||||
|
||||
def test_emptied_folder_switches_to_ondemand(rc: RecoveryContext) -> None:
|
||||
"""A mirror folder whose local files are all gone switches to online only,
|
||||
and Drive keeps every file (B255, issue #46).
|
||||
|
||||
The wipe guard rightly refuses to pass the local loss on to Drive, but it
|
||||
also blocked the push pass a queued switch runs first, so the switch never
|
||||
landed and the folder stayed in error. Through the mount afterwards, every
|
||||
file has to read back as it was.
|
||||
"""
|
||||
names: set[str] = set()
|
||||
files: dict[Path, bytes] = {}
|
||||
for _ in range(2):
|
||||
name, tree = rc.tree()
|
||||
names |= {name, f"{name}.txt"}
|
||||
files.update(tree)
|
||||
rc.pair.force_sync(rc.root)
|
||||
for child in list(rc.root.iterdir()):
|
||||
if child.is_dir() and not child.is_symlink():
|
||||
shutil.rmtree(child)
|
||||
else:
|
||||
child.unlink()
|
||||
rc.daemon.command("sync", "mode", str(rc.id), "ondemand")
|
||||
|
||||
# The folder may pass through error while the guard holds the deletions
|
||||
# back; the switch has to land anyway.
|
||||
deadline = time.monotonic() + rc.pair.timeout
|
||||
while True:
|
||||
state = rc.folder()
|
||||
if state["mode"] == "ondemand" and state.get("pending_mode") is None and is_mountpoint(rc.root):
|
||||
break
|
||||
check(time.monotonic() < deadline, f"the switch to on-demand did not land: {state}")
|
||||
time.sleep(2)
|
||||
|
||||
for relative, data in files.items():
|
||||
data_read = rc.pair.through_outage(lambda relative=relative: read(rc.root / relative), f"reading {relative}", rc.infos)
|
||||
check_bytes(data_read, data, f"{relative} on Drive after the local copy was gone")
|
||||
sentinel = rc.root / "pdfs-mode-preservation.bin"
|
||||
check_bytes(read(sentinel), rc.pair.sentinels[rc.root], f"{sentinel.name} on Drive")
|
||||
trashed = rc.trashed(names)
|
||||
check(not trashed, f"the switch trashed {trashed} on Drive")
|
||||
|
||||
|
||||
RECOVERY_CASES = [
|
||||
Case("Sync From Scratch changes nothing that already matched", test_sync_reset_changes_nothing, (LIVE,)),
|
||||
Case("regression B255: an emptied mirror folder switches to on-demand", test_emptied_folder_switches_to_ondemand, (LIVE,)),
|
||||
]
|
||||
|
||||
|
||||
def run_sync_recovery(janitor: Janitor, args) -> None:
|
||||
"""The recovery cases, in a sync folder of their own."""
|
||||
cases = select_cases(LIVE, RECOVERY_CASES)
|
||||
if not cases:
|
||||
return
|
||||
timeout = int(os.environ.get("PDFS_ACCEPTANCE_SYNC_TIMEOUT", "180"))
|
||||
path = janitor.new_sync_folder()
|
||||
pair = ManagedSyncPair([path], timeout)
|
||||
daemon = Daemon.discover(timeout)
|
||||
check(daemon is not None, "the daemon stopped answering")
|
||||
run = Run("sync folder recovery", LIVE, path)
|
||||
REPORT.append(run)
|
||||
announce(run, {"folder": f"mirror, {path}", "cases": describe_cases(LIVE, cases, RECOVERY_CASES)})
|
||||
started = time.monotonic()
|
||||
pair.create()
|
||||
context = RecoveryContext(pair, daemon)
|
||||
run_cases(run, cases, lambda _case: context, max(args.timeout, 8 * timeout), args.fail_fast)
|
||||
run.wall = time.monotonic() - started
|
||||
CONSOLE.fact("result", tally(run.counts(), run.wall))
|
||||
|
||||
|
||||
def is_mountpoint(path: Path) -> bool:
|
||||
"""True for a distinct mount, including FUSE mounts over an existing dir."""
|
||||
return os.path.ismount(path)
|
||||
@@ -4694,6 +5188,8 @@ def main() -> int:
|
||||
print(f"{','.join(case.kinds):<18} {case.name}")
|
||||
for case in MOVE_CASES:
|
||||
print(f"{'managed':<18} {case.name}")
|
||||
for case in RECOVERY_CASES:
|
||||
print(f"{'account':<18} {case.name}")
|
||||
return 0
|
||||
|
||||
install_interrupt_handlers()
|
||||
@@ -4814,6 +5310,7 @@ def run_account(reference: Run, args) -> None:
|
||||
)
|
||||
paths = [janitor.new_sync_folder(), janitor.new_sync_folder()]
|
||||
run_managed_matrix(paths, reference, args, janitor)
|
||||
run_sync_recovery(janitor, args)
|
||||
finally:
|
||||
started = time.monotonic()
|
||||
leftovers = janitor.cleanup()
|
||||
|
||||
+211
-68
@@ -1,32 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publish the user documentation in docs/ to the GitHub wiki.
|
||||
# Publish the documentation in docs/ to the GitHub wiki.
|
||||
#
|
||||
# The pages in docs/ are the source of truth; the wiki is a generated copy for
|
||||
# people who look there first. Each run clones the wiki, replaces the pages
|
||||
# listed below, rewrites links between them to wiki page names and every other
|
||||
# repository link to an absolute GitHub URL, regenerates Home and _Sidebar, and
|
||||
# commits. Nothing is pushed without --push.
|
||||
# people who look there first. Each run checks every link in docs/, clones the
|
||||
# wiki, replaces all of its pages with the ones listed in SECTIONS below,
|
||||
# rewrites links between them to wiki page names and every other repository
|
||||
# link to an absolute GitHub URL, copies the images they show, writes the
|
||||
# sidebar and footer, and commits. Nothing is pushed without --push.
|
||||
#
|
||||
# --check only checks the links and that every page in docs/ is listed or
|
||||
# excluded, without touching the wiki. CI runs it for every change to docs/.
|
||||
#
|
||||
# GitHub creates the wiki repository only when the first page is saved, so
|
||||
# create any page once in the web UI before the first run.
|
||||
# create any page once in the web UI before the first run. WIKI_REMOTE
|
||||
# overrides the remote, which is how the workflow pushes with its token.
|
||||
#
|
||||
# usage: scripts/publish-wiki.sh [--push] [WORKDIR]
|
||||
# usage: scripts/publish-wiki.sh [--check | --push] [WORKDIR]
|
||||
set -euo pipefail
|
||||
|
||||
repo_url="https://github.com/narrrl/proton-drive-linux"
|
||||
wiki_remote="git@github.com:narrrl/proton-drive-linux.wiki.git"
|
||||
wiki_remote="${WIKI_REMOTE:-git@github.com:narrrl/proton-drive-linux.wiki.git}"
|
||||
|
||||
push=0
|
||||
if [ "${1:-}" = "--push" ]; then
|
||||
push=1
|
||||
shift
|
||||
fi
|
||||
mode=commit
|
||||
case "${1:-}" in
|
||||
--check) mode=check; shift ;;
|
||||
--push) mode=push; shift ;;
|
||||
esac
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
workdir="${1:-$(mktemp -d)}"
|
||||
wiki="$workdir/wiki"
|
||||
|
||||
if [ -d "$wiki/.git" ]; then
|
||||
if [ "$mode" = check ]; then
|
||||
wiki="$workdir/check"
|
||||
mkdir -p "$wiki"
|
||||
elif [ -d "$wiki/.git" ]; then
|
||||
git -C "$wiki" pull --ff-only --quiet
|
||||
else
|
||||
git clone --quiet "$wiki_remote" "$wiki"
|
||||
@@ -35,88 +43,223 @@ fi
|
||||
rev="$(git -C "$root" rev-parse --short HEAD)"
|
||||
version="$(grep -m1 '^version' "$root/Cargo.toml" | cut -d'"' -f2)"
|
||||
|
||||
python3 - "$root" "$wiki" "$repo_url" "$version" <<'EOF'
|
||||
python3 - "$root" "$wiki" "$repo_url" "$version" "$rev" <<'EOF'
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
root, wiki, repo_url, version = sys.argv[1:]
|
||||
root, wiki, repo_url, version, rev = sys.argv[1:]
|
||||
root = pathlib.Path(root)
|
||||
wiki = pathlib.Path(wiki)
|
||||
docs = root / "docs"
|
||||
|
||||
# docs/ file -> wiki page name, in sidebar order.
|
||||
PAGES = {
|
||||
"INSTALL.md": "Installation",
|
||||
"USER_GUIDE.md": "User-Guide",
|
||||
"CLI.md": "Command-Line-Reference",
|
||||
"CONFIGURATION.md": "Configuration",
|
||||
"TROUBLESHOOTING.md": "Troubleshooting",
|
||||
"RECOVERY.md": "Recovery",
|
||||
}
|
||||
HOME = ("README.md", "Home")
|
||||
|
||||
LINK = re.compile(r"(\]\()([^)\s]+)(\))")
|
||||
# Sidebar sections: docs/ file -> wiki page name, in order.
|
||||
SECTIONS = [
|
||||
("Start here", [
|
||||
("GETTING_STARTED.md", "Getting-Started"),
|
||||
("INSTALL.md", "Installation"),
|
||||
("FAQ.md", "FAQ"),
|
||||
]),
|
||||
("Using it", [
|
||||
("USER_GUIDE.md", "User-Guide"),
|
||||
("FILES.md", "Files-and-Offline"),
|
||||
("SYNCED_FOLDERS.md", "Synced-Folders"),
|
||||
("PHOTOS.md", "Photos"),
|
||||
("SHARING.md", "Sharing"),
|
||||
("COMPUTERS.md", "Computers"),
|
||||
("LAUNCHER_AND_TRAY.md", "Launcher-and-Tray"),
|
||||
]),
|
||||
("Reference", [
|
||||
("CLI.md", "Command-Line-Reference"),
|
||||
("CONFIGURATION.md", "Configuration"),
|
||||
("SECURITY_MODEL.md", "Security-Model"),
|
||||
]),
|
||||
("When something goes wrong", [
|
||||
("TROUBLESHOOTING.md", "Troubleshooting"),
|
||||
("RECOVERY.md", "Recovery"),
|
||||
]),
|
||||
("Contributing", [
|
||||
("DEVELOPMENT.md", "Development"),
|
||||
("ARCHITECTURE.md", "Architecture"),
|
||||
("TESTING.md", "Testing"),
|
||||
("TRANSLATING.md", "Translating"),
|
||||
("ROADMAP.md", "Roadmap"),
|
||||
]),
|
||||
]
|
||||
|
||||
# Too long or too internal for the wiki; links to them go to GitHub.
|
||||
EXCLUDED = {"BUGS.md", "CHANGELOG.md"}
|
||||
|
||||
# Files outside docs/ whose links are checked but not published.
|
||||
CHECKED = ["README.md", "CONTRIBUTING.md", "SECURITY.md"]
|
||||
|
||||
PAGES = dict([HOME] + [page for _, pages in SECTIONS for page in pages])
|
||||
images_url = f"https://raw.githubusercontent.com/wiki/{repo_url.removeprefix('https://github.com/')}"
|
||||
|
||||
LINK = re.compile(r"(\]\(|src=\")([^)\"\s]+)([)\"])")
|
||||
FENCE = re.compile(r"^\s*(```|~~~)")
|
||||
INLINE_CODE = re.compile(r"(`[^`\n]*`)")
|
||||
HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
|
||||
EXPLICIT_ID = re.compile(r"<a\s+id=\"([^\"]+)\"")
|
||||
|
||||
errors = []
|
||||
images = set()
|
||||
|
||||
|
||||
def rewrite(target: str) -> str:
|
||||
if re.match(r"^[a-z]+:|^#", target):
|
||||
def outside_fences(text):
|
||||
"""Yield (is_prose, chunk) so links in code blocks are left alone."""
|
||||
chunk, fenced = [], False
|
||||
for line in text.splitlines(keepends=True):
|
||||
if FENCE.match(line):
|
||||
if chunk:
|
||||
yield not fenced, "".join(chunk)
|
||||
chunk, fenced = [], not fenced
|
||||
yield False, line
|
||||
continue
|
||||
chunk.append(line)
|
||||
if chunk:
|
||||
yield not fenced, "".join(chunk)
|
||||
|
||||
|
||||
def slug(heading):
|
||||
"""GitHub's anchor for a heading."""
|
||||
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", heading)
|
||||
return re.sub(r"[^\w\- ]", "", text.lower()).replace(" ", "-")
|
||||
|
||||
|
||||
anchor_cache = {}
|
||||
|
||||
|
||||
def anchors(path):
|
||||
if path not in anchor_cache:
|
||||
found, seen = set(), {}
|
||||
for prose, chunk in outside_fences(path.read_text()):
|
||||
if not prose:
|
||||
continue
|
||||
for line in chunk.splitlines():
|
||||
found.update(EXPLICIT_ID.findall(line))
|
||||
m = HEADING.match(line)
|
||||
if not m:
|
||||
continue
|
||||
base = slug(m.group(2))
|
||||
n = seen.get(base, 0)
|
||||
seen[base] = n + 1
|
||||
found.add(base if n == 0 else f"{base}-{n}")
|
||||
anchor_cache[path] = found
|
||||
return anchor_cache[path]
|
||||
|
||||
|
||||
def rewrite(source, target):
|
||||
"""The wiki form of one link in source, recording broken links."""
|
||||
if re.match(r"^[a-z]+:", target):
|
||||
return target
|
||||
path, _, anchor = target.partition("#")
|
||||
resolved = (source.parent / path).resolve() if path else source
|
||||
where = f"{source.relative_to(root)}: {target}"
|
||||
if not resolved.is_relative_to(root) or not resolved.exists():
|
||||
errors.append(f"{where}: no such file")
|
||||
return target
|
||||
if anchor and resolved.suffix == ".md" and anchor not in anchors(resolved):
|
||||
errors.append(f"{where}: no heading #{anchor}")
|
||||
relative = resolved.relative_to(root)
|
||||
anchor = f"#{anchor}" if anchor else ""
|
||||
if path in PAGES:
|
||||
return PAGES[path] + anchor
|
||||
# Anything else is a file in the repository: link to it on GitHub.
|
||||
resolved = (root / "docs" / path).resolve().relative_to(root)
|
||||
kind = "tree" if (root / resolved).is_dir() else "blob"
|
||||
return f"{repo_url}/{kind}/main/{resolved}{anchor}"
|
||||
if not path:
|
||||
return anchor
|
||||
if relative.parent == pathlib.Path("docs") and relative.name in PAGES:
|
||||
return PAGES[relative.name] + anchor
|
||||
if relative.parts[0] == "images":
|
||||
images.add(relative)
|
||||
return f"{images_url}/{relative}"
|
||||
kind = "tree" if resolved.is_dir() else "blob"
|
||||
return f"{repo_url}/{kind}/main/{relative}{anchor}"
|
||||
|
||||
|
||||
def convert(text: str) -> str:
|
||||
return LINK.sub(lambda m: m.group(1) + rewrite(m.group(2)) + m.group(3), text)
|
||||
def convert(source):
|
||||
out = []
|
||||
for prose, chunk in outside_fences(source.read_text()):
|
||||
if prose:
|
||||
# Odd pieces are `inline code`, which is left alone too.
|
||||
pieces = INLINE_CODE.split(chunk)
|
||||
pieces[::2] = [
|
||||
LINK.sub(lambda m: m.group(1) + rewrite(source, m.group(2)) + m.group(3), p)
|
||||
for p in pieces[::2]
|
||||
]
|
||||
chunk = "".join(pieces)
|
||||
out.append(chunk)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def title(source):
|
||||
return (docs / source).read_text().splitlines()[0].lstrip("# ").strip()
|
||||
|
||||
|
||||
def without_title(text):
|
||||
lines = text.splitlines(keepends=True)
|
||||
if lines and lines[0].startswith("# "):
|
||||
lines = lines[1:]
|
||||
while lines and not lines[0].strip():
|
||||
lines = lines[1:]
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
for name in sorted(p.name for p in docs.glob("*.md")):
|
||||
if name not in PAGES and name not in EXCLUDED:
|
||||
errors.append(f"docs/{name}: add it to SECTIONS or EXCLUDED in scripts/publish-wiki.sh")
|
||||
|
||||
converted = {source: convert(docs / source) for source in PAGES}
|
||||
published_images = set(images)
|
||||
for name in CHECKED:
|
||||
convert(root / name)
|
||||
|
||||
if errors:
|
||||
print("Broken documentation links:", file=sys.stderr)
|
||||
for error in errors:
|
||||
print(f" {error}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# The wiki is generated: anything not written below is stale.
|
||||
for old in wiki.glob("*.md"):
|
||||
old.unlink()
|
||||
shutil.rmtree(wiki / "images", ignore_errors=True)
|
||||
|
||||
note = (
|
||||
"<!-- Generated from docs/ in the repository by scripts/publish-wiki.sh."
|
||||
" Edit the source there; changes made here are overwritten. -->\n\n"
|
||||
)
|
||||
|
||||
for source, page in PAGES.items():
|
||||
text = (root / "docs" / source).read_text()
|
||||
(wiki / f"{page}.md").write_text(note + convert(text))
|
||||
(wiki / f"{page}.md").write_text(note + without_title(converted[source]))
|
||||
|
||||
for image in published_images:
|
||||
(wiki / image).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(root / image, wiki / image)
|
||||
|
||||
def title(source: str) -> str:
|
||||
return (root / "docs" / source).read_text().splitlines()[0].lstrip("# ").strip()
|
||||
|
||||
|
||||
home = [
|
||||
note,
|
||||
"# Proton Drive for Linux\n\n",
|
||||
"An unofficial Proton Drive client for the Linux desktop: your files as a folder that ",
|
||||
"downloads on demand, synced local folders, Photos, sharing, a GTK4 app with tray and search ",
|
||||
"launcher, and a scriptable CLI.\n\n",
|
||||
f"This wiki mirrors the documentation of version {version}. The source, releases and issue ",
|
||||
f"tracker are in the [repository]({repo_url}).\n\n",
|
||||
"## Pages\n\n",
|
||||
]
|
||||
home += [f"- [{title(source)}]({page})\n" for source, page in PAGES.items()]
|
||||
home += [
|
||||
"\n## More\n\n",
|
||||
f"- [Changelog]({repo_url}/blob/main/docs/CHANGELOG.md)\n",
|
||||
f"- [Architecture]({repo_url}/blob/main/docs/ARCHITECTURE.md)\n",
|
||||
f"- [Contributing]({repo_url}/blob/main/CONTRIBUTING.md)\n",
|
||||
f"- [Report a problem]({repo_url}/issues/new/choose)\n",
|
||||
]
|
||||
(wiki / "Home.md").write_text("".join(home))
|
||||
|
||||
sidebar = ["**[Home](Home)**\n\n"]
|
||||
sidebar += [f"- [{title(source)}]({page})\n" for source, page in PAGES.items()]
|
||||
sidebar = ["**[Home](Home)**\n"]
|
||||
for section, pages in SECTIONS:
|
||||
sidebar.append(f"\n**{section}**\n\n")
|
||||
sidebar += [f"- [{title(source)}]({page})\n" for source, page in pages]
|
||||
sidebar += [
|
||||
"\n---\n\n",
|
||||
f"[Changelog]({repo_url}/blob/main/docs/CHANGELOG.md) · ",
|
||||
f"[Releases]({repo_url}/releases) · [Issues]({repo_url}/issues)\n",
|
||||
]
|
||||
(wiki / "_Sidebar.md").write_text("".join(sidebar))
|
||||
(wiki / "_Sidebar.md").write_text(note + "".join(sidebar))
|
||||
|
||||
(wiki / "_Footer.md").write_text(
|
||||
note
|
||||
+ f"Documentation of Proton Drive for Linux {version}, generated from "
|
||||
+ f"[`docs/`]({repo_url}/tree/{rev}/docs) at `{rev}`. "
|
||||
+ f"To change a page, edit it in the [repository]({repo_url}).\n"
|
||||
)
|
||||
EOF
|
||||
|
||||
if [ "$mode" = check ]; then
|
||||
echo "Documentation links are fine."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cd "$wiki"
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
@@ -126,9 +269,9 @@ fi
|
||||
git commit --quiet -m "Update from docs at $rev"
|
||||
git show --stat --format='%s' HEAD
|
||||
|
||||
if [ "$push" = 1 ]; then
|
||||
if [ "$mode" = push ]; then
|
||||
git push --quiet
|
||||
echo "Pushed to $wiki_remote."
|
||||
echo "Pushed to the wiki."
|
||||
else
|
||||
echo "Committed in $wiki. Review it, then push from there or re-run with --push."
|
||||
fi
|
||||
Reference in new issue
Block a user