mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18cc2ea498 | ||
|
|
a9cb389f04 | ||
|
|
923c47acb8 | ||
|
|
478a0e6cdd | ||
|
|
2633b4ed72 | ||
|
|
b46abfc07c | ||
|
|
fd78f088ec | ||
|
|
005f7ded1f | ||
|
|
3e4886f965 | ||
|
|
aac53e3139 | ||
|
|
c2bf87596f | ||
|
|
69d4feaa07 | ||
|
|
d481dcc15c | ||
|
|
e0442c56b9 | ||
|
|
ee76f4ac60 | ||
|
|
f6c651b06b | ||
|
|
8b7d7ae87f | ||
|
|
160626b29d | ||
|
|
f1f1f8ad4d | ||
|
|
bb28a18f49 | ||
|
|
a3c0c9ee9b | ||
|
|
91fa1614ed | ||
|
|
87361a4573 | ||
|
|
6d0b6ad553 | ||
|
|
170c3f01ff | ||
|
|
976ad2aa04 | ||
|
|
b73cd087aa | ||
|
|
e402981708 | ||
|
|
453c439b74 | ||
|
|
ceba0f5849 | ||
|
|
3e0db8cf46 | ||
|
|
2a86830e72 | ||
|
|
43031ecf99 | ||
|
|
2b6c37f543 | ||
|
|
3458e2e72d | ||
|
|
0ac5928138 | ||
|
|
beb3b2e4e4 | ||
|
|
bf43476e88 | ||
|
|
6363125fec | ||
|
|
51d114a8da | ||
|
|
31be27f30e | ||
|
|
4c498d559c | ||
|
|
7fd3439b60 | ||
|
|
c4847c4d52 | ||
|
|
ad49e32de9 | ||
|
|
19854b2d76 | ||
|
|
085335ca14 | ||
|
|
9fb7e84d41 | ||
|
|
7ebe8e5210 | ||
|
|
922399b9a2 | ||
|
|
3b80e9bc8f | ||
|
|
dc8eb27c99 | ||
|
|
07d4ee42d9 | ||
|
|
0ac5e2e4fe | ||
|
|
7eb6aa0640 | ||
|
|
d13e74fc12 | ||
|
|
c0d211238b | ||
|
|
07c9b675d3 | ||
|
|
f82343b844 | ||
|
|
3e3dd7db41 | ||
|
|
d09e10dc7d | ||
|
|
8d0351124c | ||
|
|
a888e0bb28 | ||
|
|
c7fc85e943 | ||
|
|
5bfae0fee1 | ||
|
|
edd62c5f7a | ||
|
|
5c1530b4b9 | ||
|
|
ce324510de | ||
|
|
274bfd0ca2 |
No files matched your search
@@ -0,0 +1,59 @@
|
||||
name: Nix
|
||||
|
||||
# Builds the flake (docs/INSTALL.md#nixos-flake) whenever its recipe, the lock
|
||||
# files or the code change, so a NixOS user's `nix build` keeps working. Run it
|
||||
# from the Actions tab for any branch to get a build to try.
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'packaging/nix/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- 'crates/**'
|
||||
- 'po/**'
|
||||
- '.github/workflows/nix.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'packaging/nix/**'
|
||||
- 'Cargo.lock'
|
||||
- '.github/workflows/nix.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
|
||||
# Pulls from the project's binary cache, and pushes what this run built
|
||||
# when the token is there; a fork's pull request has none and only pulls.
|
||||
- uses: cachix/cachix-action@v17
|
||||
with:
|
||||
name: proton-drive-linux
|
||||
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Check the flake
|
||||
run: nix flake check --print-build-logs --no-build
|
||||
|
||||
- name: Build the package
|
||||
run: nix build --print-build-logs .#proton-drive-linux
|
||||
|
||||
- name: Smoke test
|
||||
run: |
|
||||
./result/bin/pdfs --version
|
||||
for bin in pdfs-app pdfs-tray pdfs-prompt; do test -x "result/bin/$bin"; done
|
||||
grep -q "ExecStart=$(readlink -f result)/bin/pdfs" result/lib/systemd/user/proton-drive.service
|
||||
ls result/share/locale/*/LC_MESSAGES/pdfs.mo
|
||||
@@ -153,6 +153,9 @@ jobs:
|
||||
cp LICENSE deb/usr/share/licenses/proton-drive-linux/
|
||||
po/build.sh deb/usr/share/locale
|
||||
|
||||
# Stops the running daemon before removal, so the mount stops mirroring deletes
|
||||
install -m755 packaging/deb/prerm deb/DEBIAN/prerm
|
||||
|
||||
# Calculate Installed-Size in KB for the control file
|
||||
INSTALLED_SIZE=$(du -sk deb | cut -f1)
|
||||
|
||||
@@ -230,21 +233,67 @@ jobs:
|
||||
*.rpm
|
||||
if-no-files-found: error
|
||||
|
||||
# The flake is the Nix release: a tag is what `github:narrrl/proton-drive-linux/vX.Y.Z`
|
||||
# pins, so it has to build before the release is announced.
|
||||
nix:
|
||||
name: Build the Nix package
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
|
||||
# Pulls from the project's binary cache, and pushes what this run built
|
||||
# when the token is there; a fork's pull request has none and only pulls.
|
||||
- uses: cachix/cachix-action@v17
|
||||
with:
|
||||
name: proton-drive-linux
|
||||
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Build the flake
|
||||
run: nix build --print-build-logs .#proton-drive-linux
|
||||
|
||||
- name: Check the version
|
||||
run: |
|
||||
built="$(./result/bin/pdfs --version)"
|
||||
echo "$built"
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) grep -qF "${GITHUB_REF_NAME#v}" <<< "$built" ;;
|
||||
esac
|
||||
|
||||
publish:
|
||||
name: Create GitHub Release
|
||||
needs: [checks, build-release]
|
||||
needs: [checks, build-release, nix]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Download packages
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: proton-drive-linux-builds
|
||||
|
||||
# The release notes are this version's section of the changelog, from its
|
||||
# heading up to the next one.
|
||||
- name: Extract release notes
|
||||
run: |
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
awk -v heading="## [$version]" \
|
||||
'index($0, heading) == 1 { found = 1; print; next }
|
||||
found && /^## \[/ { exit }
|
||||
found' docs/CHANGELOG.md > release-notes.md
|
||||
if [ ! -s release-notes.md ]; then
|
||||
echo "::error::docs/CHANGELOG.md has no section for $version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
body_path: release-notes.md
|
||||
files: |
|
||||
*.deb
|
||||
*.tar.gz
|
||||
|
||||
@@ -3,3 +3,5 @@
|
||||
*.pdb
|
||||
.env
|
||||
/rpmbuild
|
||||
/result
|
||||
/result-*
|
||||
Generated
+8
-8
@@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-cli"
|
||||
version = "3.1.0"
|
||||
version = "3.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3490,7 +3490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-core"
|
||||
version = "3.1.0"
|
||||
version = "3.4.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -3516,7 +3516,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-fuse"
|
||||
version = "3.1.0"
|
||||
version = "3.4.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -3538,7 +3538,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-gui"
|
||||
version = "3.1.0"
|
||||
version = "3.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3778,9 +3778,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-drive-rs"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5bde475139afb5fc2649621e1d283e243f9df2bb412f08802cdf3f4ffaf97c44"
|
||||
checksum = "9e6b87f78711e8fc72737c8eb24ebda156079308d79ab37ca2b1b32de6812880"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -3805,9 +3805,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-sdk"
|
||||
version = "0.8.0"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf32d0a03f7d13bd9bdcc8c9503e35e9df9318ca9f2a100dcf06cf50cb7ac8c"
|
||||
checksum = "19f1936755c603b98de1c82a5b343c6232e9fea8adaa77139f7cb8fcddcda697"
|
||||
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.1.0"
|
||||
version = "3.4.1"
|
||||
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.8.0" }
|
||||
proton-drive-rs = { version = "0.8.0" }
|
||||
proton-sdk = { version = "0.9.1" }
|
||||
proton-drive-rs = { version = "0.9.1" }
|
||||
|
||||
# Internal crates.
|
||||
pdfs-core = { path = "crates/pdfs-core" }
|
||||
|
||||
@@ -76,6 +76,7 @@ scriptable CLI.
|
||||
| Debian, Ubuntu 24.04 or newer | `.deb` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
|
||||
| Fedora 44 or newer | `.rpm` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
|
||||
| Arch Linux | [`proton-drive-for-linux`](https://aur.archlinux.org/packages/proton-drive-for-linux) (also `-bin` and `-git`) in the AUR |
|
||||
| NixOS | The flake in this repository: see [Install → NixOS](docs/INSTALL.md#nixos-flake) |
|
||||
| Anything else | [Build from source](docs/INSTALL.md#build-from-source) |
|
||||
|
||||
```bash
|
||||
@@ -108,6 +109,7 @@ video thumbnails), headless use and uninstalling.
|
||||
| [Configuration](docs/CONFIGURATION.md) | `config.json`, file locations, environment variables, the service |
|
||||
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Diagnosing problems and fixing common ones |
|
||||
| [Recovering on a new computer](docs/RECOVERY.md) | Getting your synced folders back after a reinstall or a lost machine |
|
||||
| [Security model](docs/SECURITY_MODEL.md) | What protects your files, what stays on this computer, what the client connects to |
|
||||
| [Changelog](docs/CHANGELOG.md) | What changed in each release |
|
||||
|
||||
For contributors: [development guide](docs/DEVELOPMENT.md), [architecture](docs/ARCHITECTURE.md),
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
Fixes land on the latest release. Please reproduce on the most recent version before reporting.
|
||||
|
||||
For what the client protects and what it does not, written for users, see the
|
||||
[security model](docs/SECURITY_MODEL.md).
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Do not open a public issue.** Report privately through GitHub's
|
||||
|
||||
+171
-41
@@ -15,7 +15,7 @@ use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
ConflictKeep, ErrorKind, MountKind, RefreshScope, Request as CtlRequest,
|
||||
Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncIssue, SyncPhase,
|
||||
pending_summary,
|
||||
daemon_outdated, pending_summary,
|
||||
};
|
||||
use pdfs_core::db::Db;
|
||||
use pdfs_core::service;
|
||||
@@ -172,17 +172,19 @@ enum Command {
|
||||
/// Mountpoint; defaults to ~/ProtonDrive.
|
||||
mountpoint: Option<PathBuf>,
|
||||
},
|
||||
/// Keep a file on this device (download + cache it). Needs a running mount.
|
||||
/// Keep a file or folder on this device (download + cache it). A folder's
|
||||
/// files download in the background; `pdfs transfers` shows the progress.
|
||||
/// Needs a running mount.
|
||||
Pin {
|
||||
/// File path, inside the mountpoint or relative to it.
|
||||
/// File or folder path, inside the mountpoint or relative to it.
|
||||
path: PathBuf,
|
||||
},
|
||||
/// Stop keeping a file on this device, evicting its cached content.
|
||||
/// Stop keeping a file or folder on this device, evicting its cached content.
|
||||
Unpin {
|
||||
/// File path, inside the mountpoint or relative to it.
|
||||
/// File or folder path, inside the mountpoint or relative to it.
|
||||
path: PathBuf,
|
||||
},
|
||||
/// List files pinned to this device.
|
||||
/// List files and folders pinned to this device.
|
||||
Pins,
|
||||
/// List a directory via the running daemon (in-app browser backend).
|
||||
Ls {
|
||||
@@ -628,6 +630,15 @@ enum DeviceCmd {
|
||||
#[arg(long, conflicts_with = "uid")]
|
||||
clear: bool,
|
||||
},
|
||||
/// Move another computer's folders into this computer's backup and sync
|
||||
/// them here. The other computer stays registered, without those folders.
|
||||
Migrate {
|
||||
/// Device uid (from `devices list`).
|
||||
uid: String,
|
||||
/// Skip the confirmation and accept every proposed local path.
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -967,6 +978,24 @@ fn cmd_devices(action: DeviceCmd) -> Result<()> {
|
||||
let uid = if clear { None } else { uid };
|
||||
ok_or_bail(control_request(CtlRequest::AdoptDevice { uid })?)?
|
||||
}
|
||||
DeviceCmd::Migrate { uid, yes } => {
|
||||
if !yes {
|
||||
println!(
|
||||
"Every folder that device backs up moves into this computer's backup.\n\
|
||||
The device stays registered, without those folders. Stop Proton Drive\n\
|
||||
on that computer first, or it sees its folders disappear."
|
||||
);
|
||||
confirm("Move them here?")?;
|
||||
}
|
||||
let response = control_request(CtlRequest::MigrateDevice { uid })?;
|
||||
// A script gets the one reply and runs `sync restore` itself.
|
||||
if emit_json(&response)? {
|
||||
return Ok(());
|
||||
}
|
||||
ok_or_bail(response)?;
|
||||
// The folders are this device's now, so the plain restore offers them.
|
||||
cmd_sync_restore(yes, None)?
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1816,20 +1845,44 @@ fn cmd_login(username: Option<String>, remember: bool) -> Result<()> {
|
||||
// Only a two-password account is ever asked for this.
|
||||
let get_mailbox = |attempt: u32| prompt_mailbox(attempt, remember);
|
||||
|
||||
// Proton may ask for a CAPTCHA first, usually on a VPN. The CLI has no
|
||||
// browser engine, so the user solves the page in their own browser and says
|
||||
// when they are done; the retry then carries the challenge token.
|
||||
let get_hv = |hv: pdfs_core::proton_sdk::api::HumanVerification| {
|
||||
println!(
|
||||
"Proton is asking for a CAPTCHA before it will accept this sign-in.\n\
|
||||
Open this page in your browser and complete it:\n\n {}\n",
|
||||
auth::browser_verification_url(&hv)
|
||||
);
|
||||
prompt_line("Press Enter once the page says you are verified")
|
||||
.map_err(|e| pdfs_core::Error::Other(format!("read verification answer: {e}")))?;
|
||||
Ok(auth::browser_verified(&hv))
|
||||
};
|
||||
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
// Solving a CAPTCHA needs a browser engine, which the CLI has no business
|
||||
// carrying. Point at the app that does rather than failing with the raw API
|
||||
// message, which reads as "your login is broken".
|
||||
rt.block_on(auth::login(&username, &password, get_totp, get_mailbox))
|
||||
.map_err(|e| match e {
|
||||
pdfs_core::Error::HumanVerificationRequired(_) => anyhow::anyhow!(
|
||||
"Proton is asking for a CAPTCHA before it will accept this sign-in.\n\
|
||||
Run `pdfs-app` and sign in there — it can show the verification page.\n\
|
||||
(This is usually triggered by a VPN or an unfamiliar IP; signing in from \
|
||||
your usual network often avoids it.)"
|
||||
),
|
||||
other => anyhow::Error::new(other).context("login failed"),
|
||||
})?;
|
||||
rt.block_on(auth::login_interactive(
|
||||
&username,
|
||||
&password,
|
||||
get_totp,
|
||||
get_mailbox,
|
||||
get_hv,
|
||||
))
|
||||
.map_err(|e| {
|
||||
// Proton words this block as "update the app", which the user can't
|
||||
// act on: the release is current and only waiting helps.
|
||||
let blocked =
|
||||
matches!(&e, pdfs_core::Error::Proton(p) if pdfs_core::error::sign_in_blocked(p));
|
||||
let e = anyhow::Error::new(e).context("login failed");
|
||||
if blocked {
|
||||
e.context(
|
||||
"Proton is temporarily refusing sign-in from this device (code 2028). \
|
||||
Your version is not the problem. Wait several hours, up to a day, \
|
||||
without trying again, then sign in.",
|
||||
)
|
||||
} else {
|
||||
e
|
||||
}
|
||||
})?;
|
||||
|
||||
// A daemon that is already up is parked in `wait_for_session`, sleeping
|
||||
// between keyring checks; restart it so the mount appears now instead of
|
||||
@@ -1988,9 +2041,13 @@ fn cmd_status() -> Result<()> {
|
||||
staged_oldest_secs,
|
||||
paused,
|
||||
paused_until,
|
||||
session_expired,
|
||||
tokens_unsaved,
|
||||
version,
|
||||
..
|
||||
}) => {
|
||||
let state = match (online, paused) {
|
||||
_ if session_expired => ", session expired: run `pdfs login`",
|
||||
(_, true) => ", sync paused",
|
||||
(false, false) => ", offline",
|
||||
(true, false) => "",
|
||||
@@ -2023,6 +2080,14 @@ fn cmd_status() -> Result<()> {
|
||||
failing_error.as_deref().unwrap_or("no error recorded"),
|
||||
);
|
||||
}
|
||||
if tokens_unsaved {
|
||||
println!(
|
||||
"Keyring refuses the refreshed sign-in; unlock it, or a reboot needs `pdfs login`"
|
||||
);
|
||||
}
|
||||
if daemon_outdated(&version) {
|
||||
println!("Daemon {}", outdated_daemon_note(&version));
|
||||
}
|
||||
}
|
||||
Ok(other) => println!("Mount: unexpected response {other:?}"),
|
||||
Err(_) => println!("Mount: not running."),
|
||||
@@ -2030,6 +2095,20 @@ fn cmd_status() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What `pdfs status` and `pdfs diagnose` say about a daemon still running
|
||||
/// an older release than this CLI.
|
||||
fn outdated_daemon_note(version: &str) -> String {
|
||||
let running = if version.is_empty() {
|
||||
"an older release".to_string()
|
||||
} else {
|
||||
version.to_string()
|
||||
};
|
||||
format!(
|
||||
"still runs {running}, this is {}; run `systemctl --user restart proton-drive` to finish the update",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
)
|
||||
}
|
||||
|
||||
fn cmd_locations() -> Result<()> {
|
||||
let response = control_request(CtlRequest::ListLocations)?;
|
||||
if emit_json(&response)? {
|
||||
@@ -2155,6 +2234,9 @@ fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
|
||||
tracing::info!("daemon stopping");
|
||||
return Ok(());
|
||||
}
|
||||
Ok(pdfs_fuse::MountOutcome::ResumeStored) => {
|
||||
tracing::info!("resuming the session stored in the keyring");
|
||||
}
|
||||
Ok(pdfs_fuse::MountOutcome::Unmounted) => {
|
||||
tracing::warn!("mount ended externally; remounting in 2s");
|
||||
pdfs_fuse::idle_sleep(std::time::Duration::from_secs(2));
|
||||
@@ -2225,9 +2307,14 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
||||
},
|
||||
);
|
||||
|
||||
// Clean unmount check (as a best-effort final backup).
|
||||
if let Err(e) = rt.block_on(auth::persist(&session)) {
|
||||
tracing::debug!(error = %e, "no new tokens to persist on shutdown");
|
||||
// Last chance for a rotation the keyring refused when it happened. Only
|
||||
// that: by now the keyring may hold a newer login, the one this stop was
|
||||
// restarted for, and the tokens of this session would overwrite it.
|
||||
if let Err(e) = auth::persist(&session) {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
"could not save the refreshed session tokens; the next start may need `pdfs login`"
|
||||
);
|
||||
}
|
||||
// Dropping the runtime waits for every blocking task, however long; a call
|
||||
// still stuck on Drive would hold the stop until systemd kills it (B165).
|
||||
@@ -3324,12 +3411,25 @@ fn cmd_diagnose() -> Result<()> {
|
||||
}
|
||||
|
||||
let db_path = dirs.db_path();
|
||||
let db_size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
|
||||
report.check(
|
||||
" database",
|
||||
db_path.is_file(),
|
||||
format!("{} ({})", db_path.display(), human_bytes(db_size)),
|
||||
);
|
||||
if db_path.exists() {
|
||||
let db_size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
|
||||
report.check(
|
||||
" database",
|
||||
db_path.is_file(),
|
||||
format!("{} ({})", db_path.display(), human_bytes(db_size)),
|
||||
);
|
||||
} else {
|
||||
// The daemon creates it on its first start, which waits for a sign-in,
|
||||
// so a fresh install has none yet (issue #39 read this as the fault).
|
||||
report.finding(
|
||||
DiagnoseLevel::Warn,
|
||||
" database",
|
||||
format!(
|
||||
"{} not created yet (the daemon creates it on first start)",
|
||||
db_path.display()
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
println!("\nFUSE");
|
||||
let fuse = Path::new("/dev/fuse");
|
||||
@@ -3388,11 +3488,21 @@ fn cmd_diagnose() -> Result<()> {
|
||||
Ok(CtlResponse::Status {
|
||||
mountpoint,
|
||||
online,
|
||||
session_expired,
|
||||
tokens_unsaved,
|
||||
pending_uploads,
|
||||
pending_changes,
|
||||
version,
|
||||
..
|
||||
}) => {
|
||||
report.finding(DiagnoseLevel::Ok, " daemon responding", "");
|
||||
if daemon_outdated(&version) {
|
||||
report.finding(
|
||||
DiagnoseLevel::Warn,
|
||||
" daemon version",
|
||||
outdated_daemon_note(&version),
|
||||
);
|
||||
}
|
||||
let live_type = mounted_fs_type(Path::new(&mountpoint));
|
||||
let is_fuse = live_type
|
||||
.as_deref()
|
||||
@@ -3409,19 +3519,38 @@ fn cmd_diagnose() -> Result<()> {
|
||||
|kind| format!("{mountpoint} ({kind})"),
|
||||
),
|
||||
);
|
||||
report.finding(
|
||||
if online {
|
||||
DiagnoseLevel::Ok
|
||||
} else {
|
||||
DiagnoseLevel::Warn
|
||||
},
|
||||
" network",
|
||||
if online {
|
||||
"online"
|
||||
} else {
|
||||
"offline; cached data remains available"
|
||||
},
|
||||
);
|
||||
// The keyring check above read the blob, which still holds the
|
||||
// tokens the daemon could not replace.
|
||||
if tokens_unsaved {
|
||||
report.finding(
|
||||
DiagnoseLevel::Warn,
|
||||
" session tokens",
|
||||
"the keyring refuses the refreshed ones; unlock it, or a reboot needs `pdfs login`",
|
||||
);
|
||||
}
|
||||
// A refused session reads as offline too, but no connection will
|
||||
// bring it back, and the keyring check above cannot tell.
|
||||
if session_expired {
|
||||
report.finding(
|
||||
DiagnoseLevel::Fail,
|
||||
" session",
|
||||
"Proton refused it (expired or revoked) — run `pdfs login`",
|
||||
);
|
||||
} else {
|
||||
report.finding(
|
||||
if online {
|
||||
DiagnoseLevel::Ok
|
||||
} else {
|
||||
DiagnoseLevel::Warn
|
||||
},
|
||||
" network",
|
||||
if online {
|
||||
"online"
|
||||
} else {
|
||||
"offline; cached data remains available"
|
||||
},
|
||||
);
|
||||
}
|
||||
let pending = pending_uploads + pending_changes;
|
||||
report.finding(
|
||||
if pending == 0 {
|
||||
@@ -3694,6 +3823,7 @@ mod login_wait_tests {
|
||||
size: 1,
|
||||
modified: 0,
|
||||
pinned: false,
|
||||
pinned_by_folder: false,
|
||||
cached: false,
|
||||
uid: "vol~link".into(),
|
||||
path: String::new(),
|
||||
|
||||
+588
-39
@@ -21,6 +21,9 @@
|
||||
//! such separation to protect and are stored, as they always were.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
@@ -31,6 +34,7 @@ use proton_sdk::api::{HumanVerification, HumanVerificationCredential};
|
||||
use proton_sdk::cache::EncryptedCacheRepository;
|
||||
use proton_sdk::config::ProtonClientConfiguration;
|
||||
use proton_sdk::error::ProtonError;
|
||||
use proton_sdk::http::Tokens;
|
||||
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
||||
use proton_sdk::telemetry::TracingTelemetry;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -109,7 +113,10 @@ fn client_config() -> ProtonClientConfiguration {
|
||||
let (app_version, user_agent) = match AppDirs::new() {
|
||||
Ok(dirs) => {
|
||||
let config = dirs.load_config();
|
||||
(config.app_version, config.user_agent)
|
||||
(
|
||||
config.resolved_app_version().to_string(),
|
||||
config.resolved_user_agent().to_string(),
|
||||
)
|
||||
}
|
||||
Err(_) => (APP_VERSION.to_string(), USER_AGENT.to_string()),
|
||||
};
|
||||
@@ -203,6 +210,44 @@ pub async fn login_interactive(
|
||||
}
|
||||
}
|
||||
|
||||
/// Proton's verification page for the user's own browser, for a front-end that
|
||||
/// cannot host it in a webview.
|
||||
///
|
||||
/// Unlike [`HumanVerification::verification_url`] this asks for the full page,
|
||||
/// not the embedded one: a browser tab has no host to post the token to, so the
|
||||
/// page has to tell the user they are done instead. Proton Mail Bridge opens the
|
||||
/// same URL.
|
||||
pub fn browser_verification_url(hv: &HumanVerification) -> String {
|
||||
let token: String = hv
|
||||
.token
|
||||
.bytes()
|
||||
.map(|b| match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
(b as char).to_string()
|
||||
}
|
||||
_ => format!("%{b:02X}"),
|
||||
})
|
||||
.collect();
|
||||
format!(
|
||||
"https://verify.proton.me/?methods={}&token={token}",
|
||||
hv.methods.join(","),
|
||||
)
|
||||
}
|
||||
|
||||
/// The credential to retry with once the user has solved the challenge in
|
||||
/// their browser.
|
||||
///
|
||||
/// The browser keeps the page's own token, so the retry carries the challenge
|
||||
/// token instead: solving the page marks that token as verified on Proton's
|
||||
/// side. The type is every offered method, as Proton Mail Bridge sends it, since
|
||||
/// the user may have picked any of them on the page.
|
||||
pub fn browser_verified(hv: &HumanVerification) -> HumanVerificationCredential {
|
||||
HumanVerificationCredential {
|
||||
token: hv.token.clone(),
|
||||
method: hv.methods.join(","),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`login`], replaying a human-verification token the user has already earned.
|
||||
///
|
||||
/// A gated login fails with [`Error::HumanVerificationRequired`] carrying the
|
||||
@@ -266,8 +311,8 @@ pub async fn login_verified(
|
||||
}
|
||||
};
|
||||
|
||||
register_refresh_handler(&session);
|
||||
let tokens = session.current_tokens().await;
|
||||
register_refresh_handler(&session, tokens.refresh_token.clone());
|
||||
let stored = StoredSession {
|
||||
session_id: session.session_id().as_str().to_owned(),
|
||||
username: session.username().to_owned(),
|
||||
@@ -319,11 +364,11 @@ fn keep_passphrases(
|
||||
let outcome = if remember {
|
||||
stored.key_passphrases = Some(encode(passphrases));
|
||||
// Best-effort: a system without a kernel keyring has nothing to clear.
|
||||
let _ = kernelkey::clear();
|
||||
let _ = kernelkey::PASSPHRASES.clear();
|
||||
Ok(())
|
||||
} else {
|
||||
stored.key_passphrases = None;
|
||||
kernelkey::store(&encode_secret(passphrases)).map_err(|e| {
|
||||
kernelkey::PASSPHRASES.store(&encode_secret(passphrases)).map_err(|e| {
|
||||
Error::Other(format!(
|
||||
"the kernel keyring is unavailable, so the unlock cannot be kept for this session: {e}"
|
||||
))
|
||||
@@ -424,7 +469,7 @@ fn find_passphrases(stored: &StoredSession) -> Result<Option<(KeyPassphrases, Ke
|
||||
if let Some(found) = stored.key_passphrases.as_ref().and_then(decode) {
|
||||
return Ok(Some((found, KeyState::Stored)));
|
||||
}
|
||||
match kernelkey::load() {
|
||||
match kernelkey::PASSPHRASES.load() {
|
||||
Ok(Some(secret)) => Ok(decode_secret(&secret).map(|p| (p, KeyState::Session))),
|
||||
Ok(None) => Ok(None),
|
||||
// No keyring on this system: nothing was ever kept there.
|
||||
@@ -450,17 +495,18 @@ pub async fn unlock(mailbox: &str, remember: bool) -> Result<()> {
|
||||
return Err(Error::ReloginRequired);
|
||||
}
|
||||
let mailbox = Zeroizing::new(mailbox.to_owned());
|
||||
let session = ProtonApiSession::resume(client_config(), stored.to_params())?;
|
||||
register_refresh_handler(&session);
|
||||
let session = resume_stored(&stored)?;
|
||||
let passphrases =
|
||||
derive_verified(&session, mailbox.as_bytes(), stored.key_salts.clone()).await?;
|
||||
keep_passphrases(stored, &passphrases, remember)
|
||||
// Read again: verifying may have rotated the tokens, and the copy read
|
||||
// above would put the spent refresh token back.
|
||||
keep_passphrases(load()?, &passphrases, remember)
|
||||
}
|
||||
|
||||
/// Drop the passphrases held in kernel memory, so the account is locked again
|
||||
/// until [`unlock`]. With `forget`, also those in the keyring blob.
|
||||
pub fn lock(forget: bool) -> Result<()> {
|
||||
kernelkey::clear()?;
|
||||
kernelkey::PASSPHRASES.clear()?;
|
||||
if forget {
|
||||
let mut stored = load()?;
|
||||
if stored.key_passphrases.take().is_some() {
|
||||
@@ -488,9 +534,10 @@ pub fn logout() -> Result<()> {
|
||||
let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
|
||||
}
|
||||
}
|
||||
if let Err(e) = kernelkey::clear() {
|
||||
if let Err(e) = kernelkey::PASSPHRASES.clear() {
|
||||
tracing::warn!(error = %e, "clearing the kernel-keyring unlock on logout failed");
|
||||
}
|
||||
let _ = kernelkey::UNSAVED_TOKENS.clear();
|
||||
match keyring_entry()?.delete_credential() {
|
||||
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
@@ -510,8 +557,7 @@ pub fn logout() -> Result<()> {
|
||||
/// a daemon starting offline can still mount from its cache.
|
||||
pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
|
||||
let stored = load()?;
|
||||
let session = ProtonApiSession::resume(client_config(), stored.to_params())?;
|
||||
register_refresh_handler(&session);
|
||||
let session = resume_stored(&stored)?;
|
||||
|
||||
let passphrases = match find_passphrases(&stored)? {
|
||||
Some((passphrases, _)) => passphrases,
|
||||
@@ -526,6 +572,48 @@ pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
|
||||
Ok((client, session))
|
||||
}
|
||||
|
||||
/// Resume `stored`, with the rotation the keyring refused last time put in
|
||||
/// first, if there is one: Proton has spent the stored refresh token then,
|
||||
/// and the kernel keyring holds the one that replaced it (B61).
|
||||
fn resume_stored(stored: &StoredSession) -> Result<ProtonApiSession> {
|
||||
let unsaved = recover_unsaved(stored);
|
||||
let mut resumed = stored.clone();
|
||||
if let Some(tokens) = &unsaved {
|
||||
tracing::info!("resuming with session tokens the keyring refused last time");
|
||||
resumed.access_token = tokens.access_token.clone();
|
||||
resumed.refresh_token = tokens.refresh_token.clone();
|
||||
}
|
||||
let session = ProtonApiSession::resume(client_config(), resumed.to_params())?;
|
||||
let sink = register_refresh_handler(&session, stored.refresh_token.clone());
|
||||
if let Some(tokens) = unsaved {
|
||||
let _ = sink.rotated(tokens);
|
||||
}
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// The rotation the kernel keyring holds for `stored`, if it belongs there.
|
||||
/// One that does not is from an older login, or was written since, and is
|
||||
/// dropped.
|
||||
fn recover_unsaved(stored: &StoredSession) -> Option<Tokens> {
|
||||
let kept = match kernelkey::UNSAVED_TOKENS.load() {
|
||||
Ok(kept) => kept?,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "kernel keyring unavailable; no unsaved tokens to recover");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match serde_json::from_slice::<UnsavedRotation>(&kept) {
|
||||
Ok(unsaved) if unsaved.belongs_in(stored) => Some(Tokens {
|
||||
access_token: unsaved.access_token,
|
||||
refresh_token: unsaved.refresh_token,
|
||||
}),
|
||||
_ => {
|
||||
let _ = kernelkey::UNSAVED_TOKENS.clear();
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a blob written by 3.0.x — which holds the mailbox password — into one
|
||||
/// that holds passphrases, so the password stops being stored.
|
||||
///
|
||||
@@ -556,11 +644,14 @@ async fn migrate_legacy(
|
||||
stored.key_salts.clone()
|
||||
};
|
||||
|
||||
let mut migrated = stored.clone();
|
||||
let verified = derive_verified(session, legacy.as_bytes(), key_salts.clone()).await;
|
||||
// Read again: the calls above may have rotated the tokens, and `stored`
|
||||
// would put the spent refresh token back.
|
||||
let mut migrated = load()?;
|
||||
migrated.mailbox_password = String::new();
|
||||
migrated.key_salts = key_salts.clone();
|
||||
migrated.key_salts = key_salts;
|
||||
|
||||
match derive_verified(session, legacy.as_bytes(), key_salts).await {
|
||||
match verified {
|
||||
Ok(passphrases) => {
|
||||
migrated.key_passphrases = Some(encode(&passphrases));
|
||||
write_stored(&migrated)?;
|
||||
@@ -618,39 +709,309 @@ fn tune(client: ProtonDriveClient, passphrases: &KeyPassphrases) -> ProtonDriveC
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the session's current tokens back to the keyring.
|
||||
/// Write any rotation of the session's tokens that has not reached the keyring
|
||||
/// yet. A best-effort last chance before the process exits.
|
||||
///
|
||||
/// Everything else in the stored blob — salts, passphrases — is preserved, so
|
||||
/// this never undoes an [`unlock`] or [`lock`] made since the session started.
|
||||
/// Call whenever the session may have rotated its tokens so a later
|
||||
/// [`resume_client`] presents a live refresh token.
|
||||
pub async fn persist(session: &ProtonApiSession) -> Result<()> {
|
||||
let tokens = session.current_tokens().await;
|
||||
store_tokens(tokens.access_token, tokens.refresh_token)
|
||||
/// Rotations are written as they happen (see [`register_refresh_handler`]), so
|
||||
/// this usually has nothing to do. It never writes the session's current
|
||||
/// tokens for their own sake: the blob may by now belong to a newer login, or
|
||||
/// hold a newer rotation, and writing them would put a spent refresh token
|
||||
/// over a live one.
|
||||
pub fn persist(session: &ProtonApiSession) -> Result<()> {
|
||||
let sink = TOKEN_SINKS
|
||||
.lock()
|
||||
.get(session.session_id().as_str())
|
||||
.cloned();
|
||||
match sink {
|
||||
Some(sink) => sink.flush(),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn store_tokens(access_token: String, refresh_token: String) -> Result<()> {
|
||||
let mut stored = load()?;
|
||||
stored.access_token = access_token;
|
||||
stored.refresh_token = refresh_token;
|
||||
write_stored(&stored)
|
||||
/// Whether the keyring holds a session newer than any this process runs on:
|
||||
/// another login, or one of this process's sessions rotated by another
|
||||
/// process (`pdfs unlock` resumes the same login and may refresh it).
|
||||
///
|
||||
/// The refresh token this process holds is spent then, and Proton refuses it
|
||||
/// as if the session were gone. A daemon asks this before it reports the
|
||||
/// session expired, and resumes the stored one instead.
|
||||
pub fn stored_session_is_newer() -> bool {
|
||||
// Nothing to compare with, and no keyring to wake up for it.
|
||||
if TOKEN_SINKS.lock().is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Ok(stored) = load() else {
|
||||
return false;
|
||||
};
|
||||
let sinks = TOKEN_SINKS.lock();
|
||||
match sinks.get(&stored.session_id) {
|
||||
Some(sink) => sink.state.lock().stored_refresh != stored.refresh_token,
|
||||
None => !sinks.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a session in this process holds refreshed tokens the keyring has
|
||||
/// not taken yet. They are retried and kept in the kernel keyring meanwhile,
|
||||
/// which a reboot clears, so the user is asked to unlock the keyring (B61).
|
||||
pub fn tokens_unsaved() -> bool {
|
||||
TOKEN_SINKS
|
||||
.lock()
|
||||
.values()
|
||||
.any(|sink| sink.state.lock().unsaved.is_some())
|
||||
}
|
||||
|
||||
/// Where each live session in this process writes its rotated tokens, by
|
||||
/// session id, for [`persist`] to find.
|
||||
static TOKEN_SINKS: parking_lot::Mutex<BTreeMap<String, Arc<TokenSink>>> =
|
||||
parking_lot::Mutex::new(BTreeMap::new());
|
||||
|
||||
/// One session's rotated tokens on their way to the keyring.
|
||||
///
|
||||
/// Proton refresh tokens are single-use, so a rotation has to reach the
|
||||
/// keyring, and it must reach only the blob it belongs to. That is the blob of
|
||||
/// this session id that still holds the refresh token this session last knew
|
||||
/// it to hold. Anything else means the blob moved on without this session: a
|
||||
/// new login, or another session on the same login (`pdfs unlock`) that
|
||||
/// rotated first. Writing into it would replace a live refresh token with a
|
||||
/// spent one, and the next start fails with `InvalidRefreshToken` (issue #35).
|
||||
struct TokenSink {
|
||||
session_id: String,
|
||||
state: parking_lot::Mutex<SinkState>,
|
||||
/// Whether a thread is retrying a write the keyring refused.
|
||||
retrying: AtomicBool,
|
||||
}
|
||||
|
||||
/// How long to wait before each new try at a write the keyring refused; the
|
||||
/// last delay repeats until it succeeds. A locked or restarting keyring
|
||||
/// usually comes back within seconds, and one that does not must not be asked
|
||||
/// in a tight loop.
|
||||
const KEYRING_RETRY: [Duration; 4] = [
|
||||
Duration::from_secs(1),
|
||||
Duration::from_secs(5),
|
||||
Duration::from_secs(30),
|
||||
Duration::from_secs(300),
|
||||
];
|
||||
|
||||
struct SinkState {
|
||||
/// The refresh token the blob holds for this session, as far as this
|
||||
/// session knows: the one it started from, or the last one it wrote.
|
||||
stored_refresh: String,
|
||||
/// The latest rotation, until the keyring has taken it.
|
||||
unsaved: Option<Tokens>,
|
||||
/// Whether `unsaved` is also in [`kernelkey::UNSAVED_TOKENS`].
|
||||
kept: bool,
|
||||
}
|
||||
|
||||
/// A rotation the keyring refused, as kept in the kernel keyring until it is
|
||||
/// written: the tokens, and the blob they go into.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct UnsavedRotation {
|
||||
session_id: String,
|
||||
/// The refresh token this rotation spent, which the blob still holds.
|
||||
spent: String,
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
impl UnsavedRotation {
|
||||
/// Whether `stored` is the blob this rotation was refused for, still
|
||||
/// holding the refresh token it spent.
|
||||
fn belongs_in(&self, stored: &StoredSession) -> bool {
|
||||
self.session_id == stored.session_id && self.spent == stored.refresh_token
|
||||
}
|
||||
}
|
||||
|
||||
/// What became of a rotation offered to a stored blob.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Rotation {
|
||||
/// The blob is this session's and took the tokens.
|
||||
Taken,
|
||||
/// The blob moved on without this session; it is left alone.
|
||||
MovedOn,
|
||||
}
|
||||
|
||||
/// Put `tokens` into `stored` if it is still this session's blob: the same
|
||||
/// session id, holding the refresh token this session last left in it, or
|
||||
/// already holding this very rotation (another process wrote it first).
|
||||
fn take_rotation(
|
||||
stored: &mut StoredSession,
|
||||
session_id: &str,
|
||||
stored_refresh: &str,
|
||||
tokens: &Tokens,
|
||||
) -> Rotation {
|
||||
if stored.session_id != session_id
|
||||
|| (stored.refresh_token != stored_refresh && stored.refresh_token != tokens.refresh_token)
|
||||
{
|
||||
return Rotation::MovedOn;
|
||||
}
|
||||
stored.access_token = tokens.access_token.clone();
|
||||
stored.refresh_token = tokens.refresh_token.clone();
|
||||
Rotation::Taken
|
||||
}
|
||||
|
||||
impl TokenSink {
|
||||
fn new(session_id: &str, stored_refresh: String) -> Self {
|
||||
Self {
|
||||
session_id: session_id.to_owned(),
|
||||
state: parking_lot::Mutex::new(SinkState {
|
||||
stored_refresh,
|
||||
unsaved: None,
|
||||
kept: false,
|
||||
}),
|
||||
retrying: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Take a new rotation and try to write it. If the keyring refuses, keep
|
||||
/// trying in the background: until then the only live refresh token is in
|
||||
/// memory, and a stop that cannot write it either loses the session
|
||||
/// (`docs/BUGS.md` B61).
|
||||
fn rotated(self: &Arc<Self>, tokens: Tokens) -> Result<()> {
|
||||
self.state.lock().unsaved = Some(tokens);
|
||||
let written = self.flush();
|
||||
if let Err(e) = &written {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
"the keyring refused the refreshed session tokens; retrying"
|
||||
);
|
||||
self.keep_in_kernel();
|
||||
self.retry_in_background();
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
/// Keep the unsaved rotation in the kernel keyring too, so a process that
|
||||
/// dies before the retry lands does not take the only live refresh token
|
||||
/// with it. The next resume puts it back (see [`resume_stored`]).
|
||||
fn keep_in_kernel(&self) {
|
||||
let mut state = self.state.lock();
|
||||
let Some(tokens) = &state.unsaved else {
|
||||
return;
|
||||
};
|
||||
let unsaved = UnsavedRotation {
|
||||
session_id: self.session_id.clone(),
|
||||
spent: state.stored_refresh.clone(),
|
||||
access_token: tokens.access_token.clone(),
|
||||
refresh_token: tokens.refresh_token.clone(),
|
||||
};
|
||||
let Ok(json) = serde_json::to_vec(&unsaved).map(Zeroizing::new) else {
|
||||
return;
|
||||
};
|
||||
match kernelkey::UNSAVED_TOKENS.store(&json) {
|
||||
Ok(()) => state.kept = true,
|
||||
Err(e) => tracing::error!(
|
||||
error = %e,
|
||||
"the kernel keyring refused them too; if this process stops first, \
|
||||
the next start needs `pdfs login`"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retry the unsaved rotation on [`KEYRING_RETRY`] until it is written or
|
||||
/// has nowhere left to go. One thread per sink; a newer rotation simply
|
||||
/// replaces what it is retrying.
|
||||
fn retry_in_background(self: &Arc<Self>) {
|
||||
if self.retrying.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let sink = Arc::clone(self);
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("pdfs-keyring-retry".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
let delays = KEYRING_RETRY
|
||||
.into_iter()
|
||||
.chain(std::iter::repeat(KEYRING_RETRY[KEYRING_RETRY.len() - 1]));
|
||||
for delay in delays {
|
||||
std::thread::sleep(delay);
|
||||
match sink.flush() {
|
||||
Ok(()) => break,
|
||||
Err(e) => tracing::debug!(error = %e, ?delay, "keyring still refuses"),
|
||||
}
|
||||
}
|
||||
sink.retrying.store(false, Ordering::SeqCst);
|
||||
// A rotation refused after the last try succeeded found
|
||||
// this thread still running, and started none of its own.
|
||||
if sink.state.lock().unsaved.is_none()
|
||||
|| sink.retrying.swap(true, Ordering::SeqCst)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
self.retrying.store(false, Ordering::SeqCst);
|
||||
tracing::error!(error = %e, "could not start retrying the keyring write");
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the unsaved rotation, if there is one. A blob that moved on, or
|
||||
/// is gone with a logout, drops it: there is nothing it may still go into.
|
||||
fn flush(&self) -> Result<()> {
|
||||
let mut state = self.state.lock();
|
||||
let Some(tokens) = state.unsaved.clone() else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut stored = match load() {
|
||||
Ok(stored) => stored,
|
||||
Err(Error::NotLoggedIn) => {
|
||||
state.settle();
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
match take_rotation(
|
||||
&mut stored,
|
||||
&self.session_id,
|
||||
&state.stored_refresh,
|
||||
&tokens,
|
||||
) {
|
||||
Rotation::Taken => {
|
||||
write_stored(&stored)?;
|
||||
state.stored_refresh = tokens.refresh_token;
|
||||
tracing::info!("successfully auto-persisted refreshed tokens in keyring");
|
||||
}
|
||||
Rotation::MovedOn => {
|
||||
tracing::info!(
|
||||
"the stored session has moved on; not writing this session's tokens over it"
|
||||
);
|
||||
}
|
||||
}
|
||||
state.settle();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl SinkState {
|
||||
/// The unsaved rotation has gone where it can: drop it, and its copy in
|
||||
/// the kernel keyring.
|
||||
fn settle(&mut self) {
|
||||
self.unsaved = None;
|
||||
if std::mem::take(&mut self.kept) {
|
||||
let _ = kernelkey::UNSAVED_TOKENS.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist rotated tokens the moment the session obtains them.
|
||||
///
|
||||
/// The handler re-reads the stored blob instead of capturing one, so it cannot
|
||||
/// write back a stale copy over a later unlock or lock — and cannot resurrect a
|
||||
/// session that was logged out while the daemon still ran.
|
||||
fn register_refresh_handler(session: &ProtonApiSession) {
|
||||
/// `stored_refresh` is the refresh token the keyring holds for this session
|
||||
/// now. The handler re-reads the stored blob instead of capturing one, so it
|
||||
/// cannot write back a stale copy over a later unlock or lock, and it writes
|
||||
/// only into a blob that is still this session's (see [`TokenSink`]), so it
|
||||
/// cannot resurrect a logged-out session or overwrite a newer login.
|
||||
fn register_refresh_handler(session: &ProtonApiSession, stored_refresh: String) -> Arc<TokenSink> {
|
||||
let session_id = session.session_id().as_str();
|
||||
let sink = Arc::new(TokenSink::new(session_id, stored_refresh));
|
||||
TOKEN_SINKS
|
||||
.lock()
|
||||
.insert(session_id.to_owned(), Arc::clone(&sink));
|
||||
let handler = Arc::clone(&sink);
|
||||
session.http().set_on_tokens_refreshed(move |tokens| {
|
||||
match store_tokens(tokens.access_token, tokens.refresh_token) {
|
||||
Ok(()) => tracing::info!("successfully auto-persisted refreshed tokens in keyring"),
|
||||
Err(Error::NotLoggedIn) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to auto-persist refreshed tokens in keyring")
|
||||
}
|
||||
}
|
||||
// A refusal is logged and retried by the sink itself.
|
||||
let _ = handler.rotated(tokens);
|
||||
});
|
||||
sink
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -671,6 +1032,30 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
fn challenge() -> HumanVerification {
|
||||
serde_json::from_str(
|
||||
r#"{"HumanVerificationToken":"ab+c/d=","HumanVerificationMethods":["captcha","email"]}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_browser_gets_the_full_verification_page() {
|
||||
assert_eq!(
|
||||
browser_verification_url(&challenge()),
|
||||
"https://verify.proton.me/?methods=captcha,email&token=ab%2Bc%2Fd%3D"
|
||||
);
|
||||
}
|
||||
|
||||
/// The browser keeps the page's token, so the retry replays the challenge
|
||||
/// itself, typed with every method the page offered.
|
||||
#[test]
|
||||
fn a_browser_verification_retries_with_the_challenge_token() {
|
||||
let credential = browser_verified(&challenge());
|
||||
assert_eq!(credential.token, "ab+c/d=");
|
||||
assert_eq!(credential.method, "captcha,email");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passphrases_survive_the_blob_encoding() {
|
||||
let decoded = decode(&encode(&passphrases())).expect("decodes");
|
||||
@@ -721,4 +1106,168 @@ mod tests {
|
||||
assert_eq!(origin, KeyState::Stored);
|
||||
assert_eq!(found, passphrases());
|
||||
}
|
||||
|
||||
fn tokens(access: &str, refresh: &str) -> Tokens {
|
||||
Tokens {
|
||||
access_token: access.into(),
|
||||
refresh_token: refresh.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_session_tokens_are_written() {
|
||||
let mut stored: StoredSession = serde_json::from_str(&blob("")).unwrap();
|
||||
assert_eq!(
|
||||
take_rotation(&mut stored, "s", "r", &tokens("a2", "r2")),
|
||||
Rotation::Taken
|
||||
);
|
||||
assert_eq!(stored.access_token, "a2");
|
||||
assert_eq!(stored.refresh_token, "r2");
|
||||
}
|
||||
|
||||
/// Issue #35: `pdfs login` stores a new session and restarts the daemon,
|
||||
/// and the old daemon's stop wrote its spent tokens into the new blob.
|
||||
#[test]
|
||||
fn tokens_of_another_session_are_not_written() {
|
||||
let mut stored: StoredSession = serde_json::from_str(&blob("")).unwrap();
|
||||
assert_eq!(
|
||||
take_rotation(&mut stored, "old", "r", &tokens("a2", "r2")),
|
||||
Rotation::MovedOn
|
||||
);
|
||||
assert_eq!(stored.refresh_token, "r");
|
||||
}
|
||||
|
||||
/// `pdfs unlock` resumes a second session on the same login. Once it has
|
||||
/// rotated, the daemon's copy of the refresh token is spent.
|
||||
#[test]
|
||||
fn tokens_are_not_written_over_a_newer_rotation_of_the_same_session() {
|
||||
let mut stored: StoredSession = serde_json::from_str(&blob("")).unwrap();
|
||||
assert_eq!(
|
||||
take_rotation(&mut stored, "s", "spent", &tokens("a2", "r2")),
|
||||
Rotation::MovedOn
|
||||
);
|
||||
assert_eq!(stored.refresh_token, "r");
|
||||
}
|
||||
|
||||
/// `pdfs unlock` wrote this very rotation first, from the same session.
|
||||
#[test]
|
||||
fn a_rotation_already_in_the_blob_is_taken() {
|
||||
let mut stored = stored_with("r2");
|
||||
assert_eq!(
|
||||
take_rotation(&mut stored, "s", "r", &tokens("a2", "r2")),
|
||||
Rotation::Taken
|
||||
);
|
||||
}
|
||||
|
||||
/// The keyring is one store per process, so the tests that use it,
|
||||
/// through `keyring-core`'s in-memory store, take turns.
|
||||
fn mock_keyring() -> parking_lot::MutexGuard<'static, ()> {
|
||||
static KEYRING: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
|
||||
let turn = KEYRING.lock();
|
||||
keyring_core::set_default_store(keyring_core::mock::Store::new().unwrap());
|
||||
turn
|
||||
}
|
||||
|
||||
fn stored_with(refresh: &str) -> StoredSession {
|
||||
serde_json::from_str(&blob("").replace(r#""r""#, &format!("{refresh:?}"))).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sink_keeps_writing_its_own_rotations_and_stops_at_a_new_login() {
|
||||
let _turn = mock_keyring();
|
||||
write_stored(&stored_with("r")).unwrap();
|
||||
|
||||
let sink = Arc::new(TokenSink::new("s", "r".into()));
|
||||
sink.rotated(tokens("a2", "r2")).unwrap();
|
||||
sink.rotated(tokens("a3", "r3")).unwrap();
|
||||
assert_eq!(load().unwrap().refresh_token, "r3");
|
||||
|
||||
// A new login replaces the blob; the old session's next rotation, and
|
||||
// its stop, leave it alone.
|
||||
let mut login = stored_with("fresh");
|
||||
login.session_id = "new".into();
|
||||
write_stored(&login).unwrap();
|
||||
sink.rotated(tokens("a4", "r4")).unwrap();
|
||||
sink.flush().unwrap();
|
||||
let now = load().unwrap();
|
||||
assert_eq!(now.session_id, "new");
|
||||
assert_eq!(now.refresh_token, "fresh");
|
||||
}
|
||||
|
||||
/// `pdfs unlock` rotated the daemon's session behind its back: the
|
||||
/// daemon's refresh token is spent, and the keyring holds the live one.
|
||||
#[test]
|
||||
fn a_session_rotated_by_another_process_is_newer_in_the_keyring() {
|
||||
let _turn = mock_keyring();
|
||||
write_stored(&stored_with("r")).unwrap();
|
||||
TOKEN_SINKS
|
||||
.lock()
|
||||
.insert("s".into(), Arc::new(TokenSink::new("s", "r".into())));
|
||||
assert!(!stored_session_is_newer());
|
||||
|
||||
write_stored(&stored_with("r2")).unwrap();
|
||||
assert!(stored_session_is_newer());
|
||||
|
||||
let mut login = stored_with("fresh");
|
||||
login.session_id = "new".into();
|
||||
write_stored(&login).unwrap();
|
||||
assert!(stored_session_is_newer());
|
||||
TOKEN_SINKS.lock().remove("s");
|
||||
}
|
||||
|
||||
/// B61: a rotation the keyring refused lived only in memory.
|
||||
#[test]
|
||||
fn a_rotation_the_keyring_refused_is_written_once_it_takes_it() {
|
||||
let _turn = mock_keyring();
|
||||
write_stored(&stored_with("r")).unwrap();
|
||||
let sink = Arc::new(TokenSink::new("s", "r".into()));
|
||||
|
||||
let entry = keyring_entry().unwrap();
|
||||
let cred = entry
|
||||
.as_any()
|
||||
.downcast_ref::<keyring_core::mock::Cred>()
|
||||
.unwrap();
|
||||
cred.set_error(keyring_core::Error::NoStorageAccess(
|
||||
std::io::Error::other("locked").into(),
|
||||
));
|
||||
assert!(sink.rotated(tokens("a2", "r2")).is_err());
|
||||
// Until it is written, a stop does not lose it: the next resume finds
|
||||
// it in the kernel keyring, where one is offered.
|
||||
let kept = sink.state.lock().kept;
|
||||
if kept {
|
||||
assert_eq!(
|
||||
recover_unsaved(&stored_with("r")).unwrap().refresh_token,
|
||||
"r2"
|
||||
);
|
||||
}
|
||||
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while load().unwrap().refresh_token != "r2" {
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"the retry never wrote the rotation"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
assert!(sink.state.lock().unsaved.is_none());
|
||||
if kept {
|
||||
assert!(kernelkey::UNSAVED_TOKENS.load().unwrap().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unsaved_rotation_goes_only_into_the_blob_it_was_refused_for() {
|
||||
let unsaved = UnsavedRotation {
|
||||
session_id: "s".into(),
|
||||
spent: "r".into(),
|
||||
access_token: "a2".into(),
|
||||
refresh_token: "r2".into(),
|
||||
};
|
||||
assert!(unsaved.belongs_in(&stored_with("r")));
|
||||
// Written since, by a retry or another process.
|
||||
assert!(!unsaved.belongs_in(&stored_with("r2")));
|
||||
let mut login = stored_with("r");
|
||||
login.session_id = "new".into();
|
||||
assert!(!unsaved.belongs_in(&login));
|
||||
}
|
||||
}
|
||||
@@ -2309,42 +2309,26 @@ mod tests {
|
||||
/// call also needs.
|
||||
#[test]
|
||||
fn budget_check_is_free_when_under_budget() {
|
||||
use std::time::Instant;
|
||||
|
||||
// Cap far above what we store, so every store stays under budget.
|
||||
let (c, _d) = cache_capped(1 << 30);
|
||||
let payload = vec![0u8; 4096];
|
||||
|
||||
// Cost of a store against a near-empty index...
|
||||
let t = Instant::now();
|
||||
for i in 0..500u64 {
|
||||
for i in 0..8u64 {
|
||||
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
|
||||
.unwrap();
|
||||
}
|
||||
let empty = t.elapsed() / 500;
|
||||
|
||||
// ...and against one with a few thousand entries in it.
|
||||
for i in 500..2500u64 {
|
||||
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
|
||||
.unwrap();
|
||||
}
|
||||
let t = Instant::now();
|
||||
for i in 2500..3000u64 {
|
||||
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
|
||||
.unwrap();
|
||||
}
|
||||
let full = t.elapsed() / 500;
|
||||
// Plant an index row far over the cap behind the running total's back,
|
||||
// and older than every real block. Any budget check that consults the
|
||||
// index would see the pool over budget, evict, and reseed the total.
|
||||
c.db.cache_touch("planted", KIND_BLOCK, 1 << 40, 0).unwrap();
|
||||
|
||||
println!("B4: store_block under budget — {empty:?} at ~0 entries, {full:?} at 2500");
|
||||
// A ratio, not a wall-clock bound: the claim is that the under-budget
|
||||
// path does not grow with the index, and only a ratio tests that. An
|
||||
// absolute threshold measures the machine's load instead, and this test
|
||||
// runs alongside the rest of the suite.
|
||||
assert!(
|
||||
full < empty * 3,
|
||||
"under-budget store should not scale with the index: \
|
||||
{empty:?} at ~0 entries vs {full:?} at 2500"
|
||||
);
|
||||
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, 8), &payload)
|
||||
.unwrap();
|
||||
|
||||
// Not a timing ratio: that measured the CI runner's load and flaked.
|
||||
assert_eq!(c.block_bytes.load(Ordering::Relaxed), 9 * 4096);
|
||||
let entries = c.db.cache_entries_by_kind(KIND_BLOCK).unwrap();
|
||||
assert_eq!(entries.len(), 10, "under-budget store evicted: {entries:?}");
|
||||
}
|
||||
|
||||
/// The running totals are a fast path, not a second source of truth: after
|
||||
|
||||
+106
-34
@@ -3,7 +3,7 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::menu::PromptConfig;
|
||||
@@ -12,8 +12,19 @@ use crate::syncignore::DEFAULT_IGNORE_PATTERNS;
|
||||
|
||||
/// Proton requires `external-drive-{name}@{semver}-{channel}` (channel ∈
|
||||
/// stable/beta/alpha); a malformed value trips the 422 anti-abuse path.
|
||||
pub const APP_VERSION: &str = "external-drive-linux@0.1.0-alpha";
|
||||
pub const USER_AGENT: &str = "proton-drive-linux/0.1.0";
|
||||
/// Follows the release so Proton can tell releases apart and retire old ones
|
||||
/// without retiring every one.
|
||||
pub const APP_VERSION: &str = concat!(
|
||||
"external-drive-linux@",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
"-stable"
|
||||
);
|
||||
pub const USER_AGENT: &str = concat!("proton-drive-linux/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
/// The fixed identity every release up to 3.1.0 wrote into `config.json`. Read
|
||||
/// back as "unset" so those configs follow the release instead of pinning it.
|
||||
const LEGACY_APP_VERSION: &str = "external-drive-linux@0.1.0-alpha";
|
||||
const LEGACY_USER_AGENT: &str = "proton-drive-linux/0.1.0";
|
||||
|
||||
/// Keyring service name; one entry per credential kind keyed by username.
|
||||
pub const KEYRING_SERVICE: &str = "proton-drive-linux";
|
||||
@@ -76,10 +87,24 @@ fn resolve_sweep_mode(env: Option<&str>, stored: Option<SweepMode>) -> SweepMode
|
||||
}
|
||||
|
||||
/// Configuration structure allowing the user to customize client identification headers.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct AppConfig {
|
||||
pub app_version: String,
|
||||
pub user_agent: String,
|
||||
/// Override for [`APP_VERSION`]. `None` means "use the release's own";
|
||||
/// the old fixed default reads as `None`, so it leaves the file on the
|
||||
/// next save.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "identity_override",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub app_version: Option<String>,
|
||||
/// Override for [`USER_AGENT`], handled like `app_version`.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "identity_override",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub user_agent: Option<String>,
|
||||
/// Soft cap on the on-disk content cache, in bytes (`0` = unlimited).
|
||||
/// `None` means "use [`DEFAULT_CACHE_BUDGET_BYTES`]"; the Settings page
|
||||
/// writes an explicit value here. Defaulted for configs predating the field.
|
||||
@@ -216,31 +241,28 @@ impl FileSort {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
app_version: APP_VERSION.to_string(),
|
||||
user_agent: USER_AGENT.to_string(),
|
||||
cache_budget: None,
|
||||
upload_limit: None,
|
||||
download_limit: None,
|
||||
mountpoint: None,
|
||||
ignore_patterns: None,
|
||||
device_uid: None,
|
||||
conflict_sweep: None,
|
||||
open_with: None,
|
||||
prompt: None,
|
||||
proton_theme: None,
|
||||
files_view: FilesView::default(),
|
||||
tray_hidden: false,
|
||||
language: None,
|
||||
online_map: false,
|
||||
local_first: None,
|
||||
}
|
||||
}
|
||||
/// Reads an `app_version` or `user_agent` override, dropping the fixed
|
||||
/// identity older releases stored as the default.
|
||||
fn identity_override<'de, D: Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Option<String>, D::Error> {
|
||||
let value = Option::<String>::deserialize(deserializer)?;
|
||||
Ok(value.filter(|v| v != LEGACY_APP_VERSION && v != LEGACY_USER_AGENT))
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
/// The `x-pm-appversion` sent to Proton: the user's override, or
|
||||
/// [`APP_VERSION`].
|
||||
pub fn resolved_app_version(&self) -> &str {
|
||||
self.app_version.as_deref().unwrap_or(APP_VERSION)
|
||||
}
|
||||
|
||||
/// The `User-Agent` sent to Proton: the user's override, or
|
||||
/// [`USER_AGENT`].
|
||||
pub fn resolved_user_agent(&self) -> &str {
|
||||
self.user_agent.as_deref().unwrap_or(USER_AGENT)
|
||||
}
|
||||
|
||||
/// The effective cache budget in bytes: the user's explicit choice, or
|
||||
/// [`DEFAULT_CACHE_BUDGET_BYTES`] when unset.
|
||||
pub fn resolved_cache_budget(&self) -> u64 {
|
||||
@@ -571,8 +593,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_app_config_serialization() {
|
||||
let config = AppConfig {
|
||||
app_version: "external-drive-test-client@1.0.0".to_string(),
|
||||
user_agent: "test-agent/1.0".to_string(),
|
||||
app_version: Some("external-drive-test-client@1.0.0".to_string()),
|
||||
user_agent: Some("test-agent/1.0".to_string()),
|
||||
cache_budget: Some(1234),
|
||||
upload_limit: Some(500_000),
|
||||
download_limit: None,
|
||||
@@ -596,8 +618,11 @@ mod tests {
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0");
|
||||
assert_eq!(decoded.user_agent, "test-agent/1.0");
|
||||
assert_eq!(
|
||||
decoded.resolved_app_version(),
|
||||
"external-drive-test-client@1.0.0"
|
||||
);
|
||||
assert_eq!(decoded.resolved_user_agent(), "test-agent/1.0");
|
||||
assert_eq!(decoded.proton_theme, Some(true));
|
||||
assert!(decoded.tray_hidden);
|
||||
assert!(decoded.online_map);
|
||||
@@ -628,8 +653,55 @@ mod tests {
|
||||
#[test]
|
||||
fn test_default_app_config() {
|
||||
let default_config = AppConfig::default();
|
||||
assert_eq!(default_config.app_version, APP_VERSION);
|
||||
assert_eq!(default_config.user_agent, USER_AGENT);
|
||||
assert_eq!(default_config.resolved_app_version(), APP_VERSION);
|
||||
assert_eq!(default_config.resolved_user_agent(), USER_AGENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_client_identity_follows_the_release() {
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
assert_eq!(
|
||||
APP_VERSION,
|
||||
format!("external-drive-linux@{version}-stable")
|
||||
);
|
||||
assert_eq!(USER_AGENT, format!("proton-drive-linux/{version}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_config_with_the_fixed_identity_follows_the_release() {
|
||||
// Every config written up to 3.1.0 holds these two values, so they must
|
||||
// not pin the identity once Proton retires 0.1.0.
|
||||
let json = format!(
|
||||
r#"{{"app_version":"{LEGACY_APP_VERSION}","user_agent":"{LEGACY_USER_AGENT}","tray_hidden":true}}"#
|
||||
);
|
||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded.resolved_app_version(), APP_VERSION);
|
||||
assert_eq!(decoded.resolved_user_agent(), USER_AGENT);
|
||||
assert!(decoded.tray_hidden, "the rest of the config survives");
|
||||
|
||||
let saved = serde_json::to_string(&decoded).unwrap();
|
||||
assert!(!saved.contains("app_version"), "{saved}");
|
||||
assert!(!saved.contains("user_agent"), "{saved}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hand_set_client_identity_is_kept() {
|
||||
let json = r#"{"app_version":"external-drive-linux@9.9.9-beta","user_agent":"custom/1"}"#;
|
||||
let decoded: AppConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(
|
||||
decoded.resolved_app_version(),
|
||||
"external-drive-linux@9.9.9-beta"
|
||||
);
|
||||
assert_eq!(decoded.resolved_user_agent(), "custom/1");
|
||||
let saved = serde_json::to_string(&decoded).unwrap();
|
||||
assert!(saved.contains("external-drive-linux@9.9.9-beta"), "{saved}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_config_without_a_client_identity_loads() {
|
||||
let decoded: AppConfig = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(decoded.resolved_app_version(), APP_VERSION);
|
||||
assert_eq!(decoded.resolved_user_agent(), USER_AGENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -446,6 +446,13 @@ pub enum Request {
|
||||
/// of registering a duplicate. `uid: None` clears the pin.
|
||||
/// Replies with [`Response::Ok`].
|
||||
AdoptDevice { uid: Option<String> },
|
||||
/// Move every folder another device backs up into this machine's own
|
||||
/// device, as server-side moves on the main volume. The other device stays
|
||||
/// registered, without those folders. Sync folders here that already track
|
||||
/// one of them follow it to its new share. Nothing is downloaded: the moved
|
||||
/// folders are then offered by [`Request::ListRestorableFolders`].
|
||||
/// Replies with [`Response::Ok`].
|
||||
MigrateDevice { uid: String },
|
||||
|
||||
// ---- device folder sync ----------------------------------
|
||||
/// Add a local folder to this machine's device, uploading its tree and
|
||||
@@ -1218,6 +1225,12 @@ pub struct JobItem {
|
||||
/// Steps known to need doing, or `0` when unknown (indeterminate progress).
|
||||
/// May *grow* mid-job as more work is discovered.
|
||||
pub total: u64,
|
||||
/// Housekeeping that changes nothing in the user's files: a mirror folder
|
||||
/// scan that finds nothing to do yet, an index rebuild, a cache check. Front
|
||||
/// ends may show it, but should not report the drive as busy over it. Absent
|
||||
/// from older daemons, which never set it.
|
||||
#[serde(default)]
|
||||
pub background: bool,
|
||||
}
|
||||
|
||||
/// One in-flight transfer in a [`Response::Transfers`] snapshot.
|
||||
@@ -1247,8 +1260,15 @@ pub struct DirEntry {
|
||||
pub size: u64,
|
||||
/// Modification time, epoch seconds.
|
||||
pub modified: i64,
|
||||
/// Whether the file is pinned to this device.
|
||||
/// Whether the node is pinned to this device, on its own or through a
|
||||
/// pinned folder above it.
|
||||
pub pinned: bool,
|
||||
/// Whether the node is kept on this device because a folder above it is
|
||||
/// pinned. Such a node cannot be unpinned on its own. Set in
|
||||
/// [`Request::ListDir`] listings only. Defaulted for wire-compat with
|
||||
/// daemons predating the field.
|
||||
#[serde(default)]
|
||||
pub pinned_by_folder: bool,
|
||||
/// Whether the file's full content is present in the local cache (a current,
|
||||
/// non-stale blob). Always false for folders. Defaulted for wire-compat with
|
||||
/// clients/daemons predating the field.
|
||||
@@ -1837,6 +1857,27 @@ pub fn pending_summary(uploads: u64, changes: u64) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the daemon that reported `version` in a [`Response::Status`] is an
|
||||
/// older release than this build. Installing a package replaces the binaries
|
||||
/// but leaves the running user service on the old one, which keeps its bugs
|
||||
/// until it restarts, while the app and the CLI already describe the new
|
||||
/// release (#33). Empty means a daemon from before the field, so older too.
|
||||
///
|
||||
/// Only older counts. A newer daemon means this front-end is the one still
|
||||
/// running from before the upgrade, and restarting the service would not help.
|
||||
pub fn daemon_outdated(version: &str) -> bool {
|
||||
fn parse(v: &str) -> Option<(u64, u64, u64)> {
|
||||
let mut parts = v.split('.').map(|p| p.parse().ok());
|
||||
let parsed = (parts.next()??, parts.next()??, parts.next()??);
|
||||
parts.next().is_none().then_some(parsed)
|
||||
}
|
||||
match (parse(version), parse(env!("CARGO_PKG_VERSION"))) {
|
||||
(Some(running), Some(this)) => running < this,
|
||||
(None, _) => version.is_empty(),
|
||||
(Some(_), None) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// One worker thread in a [`Diagnostics`] report.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct WorkerState {
|
||||
@@ -1946,6 +1987,21 @@ pub enum Response {
|
||||
/// resumed, or not paused.
|
||||
#[serde(default)]
|
||||
paused_until: Option<i64>,
|
||||
/// True when Proton refused the session itself (refresh token refused,
|
||||
/// account deleted or disabled). `online` is false then too, but no
|
||||
/// connection brings it back: only signing in again does.
|
||||
#[serde(default)]
|
||||
session_expired: bool,
|
||||
/// True while the keyring refuses the session's refreshed tokens. The
|
||||
/// daemon keeps retrying; until it succeeds, a reboot signs the user
|
||||
/// out.
|
||||
#[serde(default)]
|
||||
tokens_unsaved: bool,
|
||||
/// The release of the daemon that answered. A package upgrade does not
|
||||
/// restart the user service, so it can be older than the front-end
|
||||
/// asking; daemons before 3.3.0 omit it, which reads as empty.
|
||||
#[serde(default)]
|
||||
version: String,
|
||||
},
|
||||
/// A human-readable success message.
|
||||
Ok { message: String },
|
||||
@@ -2589,6 +2645,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #33: a daemon left running over an upgrade is named as older, also when
|
||||
/// it predates the version field, but a newer one is not, since restarting
|
||||
/// the service cannot bring it closer to this build.
|
||||
#[test]
|
||||
fn only_an_older_daemon_is_outdated() {
|
||||
assert!(!daemon_outdated(env!("CARGO_PKG_VERSION")));
|
||||
assert!(daemon_outdated(""));
|
||||
assert!(daemon_outdated("0.9.9"));
|
||||
assert!(!daemon_outdated("999.0.0"));
|
||||
assert!(!daemon_outdated("not-a-version"));
|
||||
let old: Response = serde_json::from_str(
|
||||
r#"{"Status":{"username":"u","mountpoint":"/m","pinned":0,
|
||||
"used":0,"budget":0,"pins":[]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let Response::Status { version, .. } = old else {
|
||||
panic!("not a status: {old:?}");
|
||||
};
|
||||
assert!(daemon_outdated(&version));
|
||||
}
|
||||
|
||||
/// The mutation requests must survive a line-delimited JSON round-trip, since
|
||||
/// that is exactly how they cross the control socket.
|
||||
#[test]
|
||||
@@ -3082,6 +3159,9 @@ mod tests {
|
||||
uid: Some("dev-1".into()),
|
||||
},
|
||||
Request::AdoptDevice { uid: None },
|
||||
Request::MigrateDevice {
|
||||
uid: "dev-2".into(),
|
||||
},
|
||||
Request::ListRestorableFolders,
|
||||
Request::RestoreSyncFolders {
|
||||
items: vec![RestoreItem {
|
||||
|
||||
@@ -252,6 +252,25 @@ const UID_NOW: &str = "COALESCE((SELECT uid FROM nodes WHERE lid = pending_op.li
|
||||
const PARENT_NOW: &str =
|
||||
"COALESCE((SELECT uid FROM nodes WHERE lid = pending_op.parent_lid), parent_uid)";
|
||||
|
||||
/// The `WHERE` terms for a row a drain worker may claim once it falls due: not
|
||||
/// claimed already, no other claimed op on the same node, and not under a
|
||||
/// folder that has yet to land.
|
||||
///
|
||||
/// One fragment for [`Db::claim_next_due_op`] and [`Db::earliest_due_at`]. A
|
||||
/// row the claim skips but the wait counts is due "now" for as long as it is
|
||||
/// held, and every idle worker spun on it (`docs/BUGS.md` B200).
|
||||
fn claimable() -> String {
|
||||
format!(
|
||||
"claimed_at = 0
|
||||
AND (parent_uid IS NULL OR substr({PARENT_NOW}, 1, {n}) <> '{v}~')
|
||||
AND uid NOT IN (SELECT uid FROM pending_op WHERE claimed_at <> 0)
|
||||
AND (lid IS NULL OR lid NOT IN (
|
||||
SELECT lid FROM pending_op WHERE claimed_at <> 0 AND lid IS NOT NULL))",
|
||||
v = LOCAL_VOLUME,
|
||||
n = LOCAL_VOLUME.len() + 1,
|
||||
)
|
||||
}
|
||||
|
||||
/// The volume id given to a node that exists only on this machine, so far. A
|
||||
/// real [`NodeUid`] is `{volume}~{link}`, so a placeholder is `local~<lid>` and
|
||||
/// round-trips through the same `Display`/parse path as any other uid.
|
||||
@@ -1127,15 +1146,9 @@ impl Db {
|
||||
"SELECT id, kind, {UID_NOW}, {PARENT_NOW}, name, blob_path, meta_json, created_at,
|
||||
attempts, last_error, next_attempt_at
|
||||
FROM pending_op
|
||||
WHERE next_attempt_at <= ?1
|
||||
AND claimed_at = 0
|
||||
AND (parent_uid IS NULL OR substr({PARENT_NOW}, 1, {n}) <> '{v}~')
|
||||
AND uid NOT IN (SELECT uid FROM pending_op WHERE claimed_at <> 0)
|
||||
AND (lid IS NULL OR lid NOT IN (
|
||||
SELECT lid FROM pending_op WHERE claimed_at <> 0 AND lid IS NOT NULL))
|
||||
WHERE next_attempt_at <= ?1 AND {claimable}
|
||||
ORDER BY id LIMIT 1",
|
||||
v = LOCAL_VOLUME,
|
||||
n = LOCAL_VOLUME.len() + 1,
|
||||
claimable = claimable(),
|
||||
))?;
|
||||
stmt.query_row(params![now], |r| {
|
||||
Ok(PendingOp {
|
||||
@@ -1208,19 +1221,17 @@ impl Db {
|
||||
/// debounced or backed-off op becomes eligible rather than waiting the full
|
||||
/// idle-poll interval.
|
||||
///
|
||||
/// Claimed ops are excluded: another worker is already on them, so they are
|
||||
/// not work this one is waiting for, and counting them would have an idle
|
||||
/// worker spin on a row it cannot have.
|
||||
/// Only rows [`claim_next_due_op`](Self::claim_next_due_op) could take
|
||||
/// count. A claimed op, or one held behind a claimed op on the same node,
|
||||
/// is not work this worker is waiting for, and counting it would have an
|
||||
/// idle worker spin on a row it cannot have.
|
||||
pub fn earliest_due_at(&self) -> Result<Option<i64>> {
|
||||
let conn = self.read();
|
||||
let ts: Option<i64> = conn
|
||||
.query_row(
|
||||
&format!(
|
||||
"SELECT MIN(next_attempt_at) FROM pending_op \
|
||||
WHERE claimed_at = 0 \
|
||||
AND (parent_uid IS NULL OR substr({PARENT_NOW}, 1, {n}) <> '{v}~')",
|
||||
v = LOCAL_VOLUME,
|
||||
n = LOCAL_VOLUME.len() + 1,
|
||||
"SELECT MIN(next_attempt_at) FROM pending_op WHERE {claimable}",
|
||||
claimable = claimable(),
|
||||
),
|
||||
[],
|
||||
|r| r.get(0),
|
||||
|
||||
@@ -139,6 +139,17 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-point a synced folder at the share that now holds its remote folder,
|
||||
/// after the folder moved to another device without changing its uid.
|
||||
pub fn sync_folder_set_share(&self, id: i64, share_id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE sync_folder SET remote_share_id = ?2 WHERE id = ?1",
|
||||
params![id, share_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Queue (or, with `None`, withdraw) a mode the folder should move to once it
|
||||
/// is safe to switch.
|
||||
pub fn sync_folder_set_pending_mode(&self, id: i64, mode: Option<&str>) -> Result<()> {
|
||||
|
||||
@@ -1689,9 +1689,16 @@ fn writing_nodes_does_not_scale_with_the_size_of_the_index() {
|
||||
.collect();
|
||||
db.upsert_nodes(&batch).unwrap();
|
||||
|
||||
let t = Instant::now();
|
||||
db.upsert_nodes(&batch).unwrap();
|
||||
t.elapsed()
|
||||
// The fastest of a few runs: the rest of the suite runs alongside, and
|
||||
// one run that lost the CPU to it read as the regression.
|
||||
(0..5)
|
||||
.map(|_| {
|
||||
let t = Instant::now();
|
||||
db.upsert_nodes(&batch).unwrap();
|
||||
t.elapsed()
|
||||
})
|
||||
.min()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
let small = cost_against_index_of(200);
|
||||
@@ -4940,6 +4947,28 @@ fn pending_mode_is_queued_until_the_mode_is_reached() {
|
||||
assert_eq!(folder.pending_mode, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_sync_folder_points_at_its_new_share() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let id = db
|
||||
.sync_folder_add("/tmp/pdfs-moved-share", "vol~folder", "windows-share")
|
||||
.unwrap();
|
||||
let other = db
|
||||
.sync_folder_add("/tmp/pdfs-unmoved-share", "vol~other", "windows-share")
|
||||
.unwrap();
|
||||
|
||||
db.sync_folder_set_share(id, "linux-share").unwrap();
|
||||
|
||||
let folder = db.sync_folder_get(id).unwrap().unwrap();
|
||||
assert_eq!(folder.remote_share_id, "linux-share");
|
||||
// A move keeps the uid, so nothing but the share changes.
|
||||
assert_eq!(folder.remote_uid, "vol~folder");
|
||||
assert_eq!(
|
||||
db.sync_folder_get(other).unwrap().unwrap().remote_share_id,
|
||||
"windows-share"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_commit_clears_only_the_intent_it_satisfies() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
@@ -6525,6 +6554,36 @@ fn an_idle_worker_does_not_wait_on_an_op_another_worker_has() {
|
||||
);
|
||||
}
|
||||
|
||||
/// B200: a rename queued behind a long upload of the same node is due, but no
|
||||
/// worker may claim it until the upload retires. Counted as due, it had every
|
||||
/// idle worker wake at once, find nothing, and go round again.
|
||||
#[test]
|
||||
fn earliest_due_at_skips_an_op_held_behind_a_claimed_one() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let root = uid("root").to_string();
|
||||
let mut revision = bulk_op(0, &root);
|
||||
revision.parent_uid = None;
|
||||
db.enqueue_op(&revision).unwrap();
|
||||
db.enqueue_op(&PendingOp {
|
||||
kind: OP_RENAME.to_string(),
|
||||
parent_uid: Some(root.clone()),
|
||||
blob_path: None,
|
||||
meta_json: None,
|
||||
..bulk_op(0, &root)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
db.claim_next_due_op(10)
|
||||
.unwrap()
|
||||
.expect("the revision is due");
|
||||
assert!(db.claim_next_due_op(10).unwrap().is_none());
|
||||
assert_eq!(
|
||||
db.earliest_due_at().unwrap(),
|
||||
None,
|
||||
"a row no worker can claim is not work an idle worker is waiting for"
|
||||
);
|
||||
}
|
||||
|
||||
/// Trashing a photo from the gallery must not leave it behind on an album page:
|
||||
/// the row goes, its album membership goes with it, and nothing else moves.
|
||||
#[test]
|
||||
|
||||
@@ -154,17 +154,17 @@ impl CoreError {
|
||||
/// through the boxes the drain deals in, where the concrete type survives
|
||||
/// but the static one does not.
|
||||
///
|
||||
/// A failure that is not an API response at all — a transport error, a
|
||||
/// timeout, a TLS failure — is what being offline looks like from here, so
|
||||
/// it classifies as [`ErrorKind::Offline`] rather than
|
||||
/// [`ErrorKind::Remote`]: the request never reached Proton, and telling the
|
||||
/// user their connection is down beats telling them the server refused.
|
||||
/// A transport error, a timeout or a TLS failure is what being offline
|
||||
/// looks like from here, so it classifies as [`ErrorKind::Offline`] rather
|
||||
/// than [`ErrorKind::Remote`]: the request never reached Proton, and
|
||||
/// telling the user their connection is down beats telling them the
|
||||
/// server refused. The SDK's other failures say what went wrong instead
|
||||
/// (see [`proton_kind`]).
|
||||
pub fn from_api(e: &(dyn std::error::Error + 'static), what: &str) -> Self {
|
||||
let kind = match e.downcast_ref::<proton_sdk::ProtonError>() {
|
||||
Some(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
|
||||
// A `ProtonError` that is not an `Api` variant never reached the
|
||||
// API; so does anything that is not a `ProtonError` at all.
|
||||
_ => ErrorKind::Offline,
|
||||
Some(proton) => proton_kind(proton),
|
||||
// Not the SDK's at all: a timeout around the call, or the socket.
|
||||
None => ErrorKind::Offline,
|
||||
};
|
||||
Self::new(kind, format!("{what}: {e}"))
|
||||
}
|
||||
@@ -214,15 +214,33 @@ impl From<Error> for CoreError {
|
||||
| Error::Locked
|
||||
| Error::WrongMailboxPassword
|
||||
| Error::HumanVerificationRequired(_) => ErrorKind::Denied,
|
||||
Error::Proton(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
|
||||
// A `ProtonError` that never reached the API is a transport failure.
|
||||
Error::Proton(_) => ErrorKind::Offline,
|
||||
Error::Proton(proton) => proton_kind(proton),
|
||||
_ => ErrorKind::Internal,
|
||||
};
|
||||
Self::new(kind, e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// What a failed SDK call means to a front-end.
|
||||
///
|
||||
/// Only a transport failure is the connection. The rest happened on this
|
||||
/// machine, and calling them "offline" sent the user to check a network that
|
||||
/// worked (issue #38: a photo upload on an account without a photo library
|
||||
/// read "You're offline").
|
||||
fn proton_kind(e: &proton_sdk::ProtonError) -> ErrorKind {
|
||||
use proton_sdk::ProtonError as P;
|
||||
match e {
|
||||
P::Api(api) => api_kind(api.code),
|
||||
P::Transport(_) => ErrorKind::Offline,
|
||||
// Proton answered, but not with anything this client can read.
|
||||
P::Serialization(_) => ErrorKind::Remote,
|
||||
// The SDK refused the call before sending it, such as an upload to a
|
||||
// photo library the account does not have.
|
||||
P::InvalidOperation(_) => ErrorKind::Invalid,
|
||||
P::Crypto(_) | P::KeysLocked => ErrorKind::Internal,
|
||||
}
|
||||
}
|
||||
|
||||
/// What an API response code means to a front-end.
|
||||
///
|
||||
/// The grouping is by *what the user can do about it*, which is what
|
||||
@@ -278,6 +296,32 @@ fn api_kind(code: proton_sdk::api::ResponseCode) -> ErrorKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `e` says the session itself is gone: its refresh token was refused,
|
||||
/// or the account was deleted or disabled.
|
||||
///
|
||||
/// Nothing but a new login helps then, and no retry will ever succeed, so a
|
||||
/// caller must not take it for a lost connection and wait for it to come back
|
||||
/// (issue #35: a revoked session was shown as "offline" for good).
|
||||
pub fn session_revoked(e: &proton_sdk::ProtonError) -> bool {
|
||||
use proton_sdk::api::ResponseCode as C;
|
||||
match e {
|
||||
proton_sdk::ProtonError::Api(api) => {
|
||||
api.is_invalid_refresh_token()
|
||||
|| matches!(api.code, C::AccountDeleted | C::AccountDisabled)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `e` is Proton's temporary block on a sign-in (code 2028).
|
||||
///
|
||||
/// Its message says the app version is no longer supported, which is not what
|
||||
/// happened: the release is current and only waiting helps (issues #28, #39).
|
||||
/// A front-end must say that instead of passing the message through.
|
||||
pub fn sign_in_blocked(e: &proton_sdk::ProtonError) -> bool {
|
||||
matches!(e, proton_sdk::ProtonError::Api(api) if api.code == proton_sdk::api::ResponseCode::Blocked)
|
||||
}
|
||||
|
||||
/// The result every request-serving method in the daemon returns.
|
||||
pub type CoreResult<T> = std::result::Result<T, CoreError>;
|
||||
|
||||
@@ -309,6 +353,25 @@ mod tests {
|
||||
assert_eq!(api_kind(C::ProtonDriveUnknown), ErrorKind::Remote);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_refused_refresh_or_a_closed_account_revokes_the_session() {
|
||||
assert!(session_revoked(&api(C::InvalidRefreshToken)));
|
||||
assert!(session_revoked(&api(C::AccountDeleted)));
|
||||
assert!(session_revoked(&api(C::AccountDisabled)));
|
||||
// One file the account may not touch says nothing about the session.
|
||||
assert!(!session_revoked(&api(C::Forbidden)));
|
||||
assert!(!session_revoked(&api(C::Timeout)));
|
||||
assert!(!session_revoked(&ProtonError::KeysLocked));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_code_2028_is_a_blocked_sign_in() {
|
||||
assert!(sign_in_blocked(&api(C::Blocked)));
|
||||
assert!(!sign_in_blocked(&api(C::OutdatedApp)));
|
||||
assert!(!sign_in_blocked(&api(C::TooManyRequests)));
|
||||
assert!(!sign_in_blocked(&ProtonError::KeysLocked));
|
||||
}
|
||||
|
||||
/// The regression this whole pass exists to prevent: before it, every SDK
|
||||
/// failure was hardcoded to `Remote`, so a 403 asked the user to retry a
|
||||
/// request that could never succeed.
|
||||
@@ -339,6 +402,18 @@ mod tests {
|
||||
assert_eq!(CoreError::from_api(&io, "list").kind, ErrorKind::Offline);
|
||||
}
|
||||
|
||||
/// Issue #38: the SDK refused a photo upload before sending it, and the
|
||||
/// app said "You're offline" while every other call went through.
|
||||
#[test]
|
||||
fn a_call_the_sdk_refuses_to_send_is_not_offline() {
|
||||
let refused = ProtonError::invalid_operation("account has no photos volume");
|
||||
let e = CoreError::from_api(&refused, "upload photo failed");
|
||||
assert_eq!(e.kind, ErrorKind::Invalid);
|
||||
assert!(e.message.ends_with("account has no photos volume"));
|
||||
let e: CoreError = Error::Proton(ProtonError::KeysLocked).into();
|
||||
assert_ne!(e.kind, ErrorKind::Offline);
|
||||
}
|
||||
|
||||
/// `Error` is the plumbing type; converting it must classify as sharply as
|
||||
/// the direct path, or the two disagree about the same failure.
|
||||
#[test]
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
//! kept in kernel memory — not on disk, not in the Secret Service — and vanish
|
||||
//! when the user's last session ends or the machine reboots. Both the daemon and
|
||||
//! `pdfs unlock` reach the same entry because it hangs off the *user* keyring.
|
||||
//! It is also where session tokens wait while the Secret Service refuses them
|
||||
//! ([`UNSAVED_TOKENS`]), so they outlive the process that holds them.
|
||||
//!
|
||||
//! Kernel keys are not paged to swap, are invisible to other users, and are
|
||||
//! wiped by [`clear`] (`KEYCTL_REVOKE`), which makes them a better home than a
|
||||
@@ -20,12 +22,34 @@ use std::io;
|
||||
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// Key description. Namespaced so it cannot collide with another program's key.
|
||||
/// One secret in the user keyring, by its description. Descriptions are
|
||||
/// namespaced so they cannot collide with another program's keys.
|
||||
pub struct Key {
|
||||
description: &'static [u8],
|
||||
}
|
||||
|
||||
/// The account's key passphrases (see the module docs).
|
||||
#[cfg(not(test))]
|
||||
const DESCRIPTION: &[u8] = b"pdfs:key-passphrases\0";
|
||||
pub const PASSPHRASES: Key = Key {
|
||||
description: b"pdfs:key-passphrases\0",
|
||||
};
|
||||
/// Tests must never touch a real unlock.
|
||||
#[cfg(test)]
|
||||
const DESCRIPTION: &[u8] = b"pdfs:key-passphrases:test\0";
|
||||
pub const PASSPHRASES: Key = Key {
|
||||
description: b"pdfs:key-passphrases:test\0",
|
||||
};
|
||||
|
||||
/// Session tokens the Secret Service refused to store, until it takes them.
|
||||
/// A refresh token held only in a process's memory dies with the process.
|
||||
#[cfg(not(test))]
|
||||
pub const UNSAVED_TOKENS: Key = Key {
|
||||
description: b"pdfs:unsaved-tokens\0",
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub const UNSAVED_TOKENS: Key = Key {
|
||||
description: b"pdfs:unsaved-tokens:test\0",
|
||||
};
|
||||
|
||||
const KEY_TYPE: &[u8] = b"user\0";
|
||||
|
||||
const KEY_SPEC_USER_KEYRING: i32 = -4;
|
||||
@@ -49,7 +73,7 @@ fn last_error<T>() -> io::Result<T> {
|
||||
Err(io::Error::last_os_error())
|
||||
}
|
||||
|
||||
fn find() -> io::Result<Option<libc::c_long>> {
|
||||
fn find(description: &[u8]) -> io::Result<Option<libc::c_long>> {
|
||||
// SAFETY: both strings are NUL-terminated and outlive the call.
|
||||
let id = unsafe {
|
||||
libc::syscall(
|
||||
@@ -57,7 +81,7 @@ fn find() -> io::Result<Option<libc::c_long>> {
|
||||
KEYCTL_SEARCH,
|
||||
KEY_SPEC_USER_KEYRING,
|
||||
KEY_TYPE.as_ptr(),
|
||||
DESCRIPTION.as_ptr(),
|
||||
description.as_ptr(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
@@ -83,100 +107,102 @@ pub fn forbid_dumps() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Store `secret`, replacing any earlier one.
|
||||
pub fn store(secret: &[u8]) -> io::Result<()> {
|
||||
// SAFETY: strings are NUL-terminated; the payload pointer/length pair
|
||||
// describes `secret`, which outlives the call.
|
||||
let id = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_add_key,
|
||||
KEY_TYPE.as_ptr(),
|
||||
DESCRIPTION.as_ptr(),
|
||||
secret.as_ptr(),
|
||||
secret.len(),
|
||||
KEY_SPEC_USER_KEYRING,
|
||||
)
|
||||
};
|
||||
if id < 0 {
|
||||
return last_error();
|
||||
}
|
||||
// SAFETY: plain integer arguments.
|
||||
let rc = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_keyctl,
|
||||
KEYCTL_SETPERM,
|
||||
id,
|
||||
PERM_OWNER_AND_POSSESSOR,
|
||||
)
|
||||
};
|
||||
if rc < 0 {
|
||||
// A key whose permissions are unknown is not one to leave behind.
|
||||
let err = io::Error::last_os_error();
|
||||
let _ = clear();
|
||||
return Err(err);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the stored secret, or `None` when there is none.
|
||||
pub fn load() -> io::Result<Option<Zeroizing<Vec<u8>>>> {
|
||||
let Some(id) = find()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Size first, then read; retry if the key was replaced in between.
|
||||
for _ in 0..3 {
|
||||
// SAFETY: a null buffer asks the kernel only for the payload length.
|
||||
let len = unsafe {
|
||||
impl Key {
|
||||
/// Store `secret`, replacing any earlier one.
|
||||
pub fn store(&self, secret: &[u8]) -> io::Result<()> {
|
||||
// SAFETY: strings are NUL-terminated; the payload pointer/length pair
|
||||
// describes `secret`, which outlives the call.
|
||||
let id = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_keyctl,
|
||||
KEYCTL_READ,
|
||||
id,
|
||||
std::ptr::null_mut::<u8>(),
|
||||
0usize,
|
||||
libc::SYS_add_key,
|
||||
KEY_TYPE.as_ptr(),
|
||||
self.description.as_ptr(),
|
||||
secret.as_ptr(),
|
||||
secret.len(),
|
||||
KEY_SPEC_USER_KEYRING,
|
||||
)
|
||||
};
|
||||
if len < 0 {
|
||||
if id < 0 {
|
||||
return last_error();
|
||||
}
|
||||
// SAFETY: plain integer arguments.
|
||||
let rc = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_keyctl,
|
||||
KEYCTL_SETPERM,
|
||||
id,
|
||||
PERM_OWNER_AND_POSSESSOR,
|
||||
)
|
||||
};
|
||||
if rc < 0 {
|
||||
// A key whose permissions are unknown is not one to leave behind.
|
||||
let err = io::Error::last_os_error();
|
||||
let _ = self.clear();
|
||||
return Err(err);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the stored secret, or `None` when there is none.
|
||||
pub fn load(&self) -> io::Result<Option<Zeroizing<Vec<u8>>>> {
|
||||
let Some(id) = find(self.description)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Size first, then read; retry if the key was replaced in between.
|
||||
for _ in 0..3 {
|
||||
// SAFETY: a null buffer asks the kernel only for the payload length.
|
||||
let len = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_keyctl,
|
||||
KEYCTL_READ,
|
||||
id,
|
||||
std::ptr::null_mut::<u8>(),
|
||||
0usize,
|
||||
)
|
||||
};
|
||||
if len < 0 {
|
||||
return match io::Error::last_os_error().raw_os_error() {
|
||||
Some(libc::ENOKEY | libc::EKEYREVOKED | libc::EKEYEXPIRED) => Ok(None),
|
||||
_ => last_error(),
|
||||
};
|
||||
}
|
||||
let mut buf = Zeroizing::new(vec![0u8; len as usize]);
|
||||
// SAFETY: `buf` is `len` writable bytes.
|
||||
let read = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_keyctl,
|
||||
KEYCTL_READ,
|
||||
id,
|
||||
buf.as_mut_ptr(),
|
||||
buf.len(),
|
||||
)
|
||||
};
|
||||
if read < 0 {
|
||||
return last_error();
|
||||
}
|
||||
if read as usize <= buf.len() {
|
||||
buf.truncate(read as usize);
|
||||
return Ok(Some(buf));
|
||||
}
|
||||
}
|
||||
Err(io::Error::other("the key changed while it was being read"))
|
||||
}
|
||||
|
||||
/// Remove the stored secret. Succeeds when there is none.
|
||||
pub fn clear(&self) -> io::Result<()> {
|
||||
let Some(id) = find(self.description)? else {
|
||||
return Ok(());
|
||||
};
|
||||
// SAFETY: plain integer arguments.
|
||||
let rc = unsafe { libc::syscall(libc::SYS_keyctl, KEYCTL_REVOKE, id) };
|
||||
if rc < 0 {
|
||||
return match io::Error::last_os_error().raw_os_error() {
|
||||
Some(libc::ENOKEY | libc::EKEYREVOKED | libc::EKEYEXPIRED) => Ok(None),
|
||||
Some(libc::ENOKEY | libc::EKEYREVOKED) => Ok(()),
|
||||
_ => last_error(),
|
||||
};
|
||||
}
|
||||
let mut buf = Zeroizing::new(vec![0u8; len as usize]);
|
||||
// SAFETY: `buf` is `len` writable bytes.
|
||||
let read = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_keyctl,
|
||||
KEYCTL_READ,
|
||||
id,
|
||||
buf.as_mut_ptr(),
|
||||
buf.len(),
|
||||
)
|
||||
};
|
||||
if read < 0 {
|
||||
return last_error();
|
||||
}
|
||||
if read as usize <= buf.len() {
|
||||
buf.truncate(read as usize);
|
||||
return Ok(Some(buf));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(io::Error::other("the key changed while it was being read"))
|
||||
}
|
||||
|
||||
/// Remove the stored secret. Succeeds when there is none.
|
||||
pub fn clear() -> io::Result<()> {
|
||||
let Some(id) = find()? else {
|
||||
return Ok(());
|
||||
};
|
||||
// SAFETY: plain integer arguments.
|
||||
let rc = unsafe { libc::syscall(libc::SYS_keyctl, KEYCTL_REVOKE, id) };
|
||||
if rc < 0 {
|
||||
return match io::Error::last_os_error().raw_os_error() {
|
||||
Some(libc::ENOKEY | libc::EKEYREVOKED) => Ok(()),
|
||||
_ => last_error(),
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -187,18 +213,21 @@ mod tests {
|
||||
// so one test covers the whole round trip and skips when it is unavailable.
|
||||
#[test]
|
||||
fn stored_secret_round_trips_and_clears() {
|
||||
if store(b"probe").is_err() {
|
||||
if PASSPHRASES.store(b"probe").is_err() {
|
||||
eprintln!("kernel keyring unavailable; skipping");
|
||||
return;
|
||||
}
|
||||
let saved = load().unwrap().expect("stored secret is readable");
|
||||
let saved = PASSPHRASES
|
||||
.load()
|
||||
.unwrap()
|
||||
.expect("stored secret is readable");
|
||||
assert_eq!(saved.as_slice(), b"probe");
|
||||
|
||||
store(b"replaced").unwrap();
|
||||
assert_eq!(load().unwrap().unwrap().as_slice(), b"replaced");
|
||||
PASSPHRASES.store(b"replaced").unwrap();
|
||||
assert_eq!(PASSPHRASES.load().unwrap().unwrap().as_slice(), b"replaced");
|
||||
|
||||
clear().unwrap();
|
||||
assert!(load().unwrap().is_none());
|
||||
clear().unwrap();
|
||||
PASSPHRASES.clear().unwrap();
|
||||
assert!(PASSPHRASES.load().unwrap().is_none());
|
||||
PASSPHRASES.clear().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use super::*;
|
||||
use crate::shutdown::Shutdown;
|
||||
use pdfs_core::error::session_revoked;
|
||||
|
||||
/// Drop `uid` from one mount's tree and tell that mount's kernel session the
|
||||
/// entry is gone. Shared by the trash and delete paths, which differ only in
|
||||
@@ -402,6 +403,12 @@ pub(super) async fn run_event_sync(
|
||||
}
|
||||
break Some(c.clone());
|
||||
}
|
||||
// Refused for good: retrying would be refused the same way
|
||||
// for the life of the daemon. The next login restarts it.
|
||||
Err(e) if session_revoked(&e) => {
|
||||
core.mark_session_expired("seed event cursor", &e);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, ?delay, "seed event cursor failed; retrying");
|
||||
tokio::time::sleep(delay).await;
|
||||
@@ -502,6 +509,10 @@ pub(super) async fn run_photos_event_sync(core: Core) {
|
||||
debug!("no photos volume; photo event sync not started");
|
||||
return;
|
||||
}
|
||||
Err(error) if session_revoked(&error) => {
|
||||
core.mark_session_expired("read the photos root", &error);
|
||||
return;
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(%error, "reading the photos root failed; retrying");
|
||||
tokio::time::sleep(ONLINE_PROBE_MAX).await;
|
||||
@@ -710,7 +721,7 @@ fn scan_local_once(
|
||||
|
||||
// The walk has no idea how many files it will find, so the job counts what it
|
||||
// has seen and stays indeterminate.
|
||||
let job = transfers.begin_job("Indexing this computer");
|
||||
let job = transfers.begin_background_job("Indexing this computer");
|
||||
job.detail("Scanning your files");
|
||||
let walked = localindex::scan(&[home], &excludes, |batch| {
|
||||
if let Err(e) = db.local_upsert_batch(generation, &batch) {
|
||||
|
||||
@@ -334,6 +334,9 @@ pub(crate) fn status_response(core: &Core, username: &str, mountpoint: &Path) ->
|
||||
budget: core.cache.budget(),
|
||||
pins,
|
||||
online: core.online.load(Ordering::Relaxed),
|
||||
session_expired: core.session_expired(),
|
||||
tokens_unsaved: pdfs_core::auth::tokens_unsaved(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
pending_uploads: queued.uploads.max(0) as u64 + landing,
|
||||
pending_changes: queued.changes.max(0) as u64,
|
||||
}
|
||||
@@ -369,7 +372,7 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
|
||||
RefreshScope::Photos { .. } => &[],
|
||||
},
|
||||
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
|
||||
R::AdoptDevice { .. } => &[Topic::Devices, Topic::Locations],
|
||||
R::AdoptDevice { .. } | R::MigrateDevice { .. } => &[Topic::Devices, Topic::Locations],
|
||||
R::AddSyncFolder { .. }
|
||||
| R::RemoveSyncFolder { .. }
|
||||
| R::SetSyncFolderMode { .. }
|
||||
@@ -938,6 +941,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// The reply goes to the app's toast only; without
|
||||
// this the journal shows a failure and no reason.
|
||||
warn!(error = %e, "photo upload failed");
|
||||
CtlResponse::error(CoreError::from_api(&e, "upload photo failed"))
|
||||
}
|
||||
}
|
||||
@@ -1297,6 +1303,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Ok(message) => CtlResponse::Ok { message },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::MigrateDevice { uid }) => match core.migrate_device(&uid) {
|
||||
Ok(message) => CtlResponse::Ok { message },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::AddSyncFolder { local_path }) => {
|
||||
// Registering the device and uploading a folder tree far outlasts the
|
||||
// socket read timeout, so ack immediately and work on a background
|
||||
|
||||
+185
-14
@@ -19,6 +19,7 @@ use pdfs_core::control::{
|
||||
};
|
||||
use pdfs_core::db::{StoredDevice, StoredSyncFolder};
|
||||
use pdfs_core::mounts::MountMode;
|
||||
use pdfs_core::profile::PROFILE_DIR_NAME;
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid};
|
||||
use proton_drive_rs::{DeviceType, Node};
|
||||
@@ -114,6 +115,22 @@ pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> {
|
||||
.map_err(|e| CoreError::internal(format!("write config: {e}")))
|
||||
}
|
||||
|
||||
/// The name a folder moved in from `device` takes when `name` is already
|
||||
/// taken under this device's root, or `None` when it can keep its own.
|
||||
fn migrated_name(name: &str, device: &str, taken: &[String]) -> Option<String> {
|
||||
let free = |candidate: &str| !taken.iter().any(|t| t == candidate);
|
||||
if free(name) {
|
||||
return None;
|
||||
}
|
||||
let base = format!("{name} ({device})");
|
||||
if free(&base) {
|
||||
return Some(base);
|
||||
}
|
||||
(2..)
|
||||
.map(|n| format!("{base} {n}"))
|
||||
.find(|candidate| free(candidate))
|
||||
}
|
||||
|
||||
impl Core {
|
||||
// ---- devices ----------------------------------------------------------
|
||||
|
||||
@@ -140,7 +157,7 @@ impl Core {
|
||||
pub(crate) fn list_devices(&self) -> CoreResult<Vec<DeviceInfo>> {
|
||||
let devices = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_devices())
|
||||
.block_on(self.drive.enumerate_devices())
|
||||
.map_err(|e| CoreError::from_api(&e, "list devices"))?;
|
||||
// No cached device row yet means this machine syncs nothing, so none of
|
||||
// the listed devices is ours.
|
||||
@@ -197,7 +214,7 @@ impl Core {
|
||||
};
|
||||
let remote = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_devices())
|
||||
.block_on(self.drive.enumerate_devices())
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
||||
let dev = remote
|
||||
.into_iter()
|
||||
@@ -240,7 +257,7 @@ impl Core {
|
||||
};
|
||||
let remote = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_devices())
|
||||
.block_on(self.drive.enumerate_devices())
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
||||
let d = remote
|
||||
.into_iter()
|
||||
@@ -260,6 +277,107 @@ impl Core {
|
||||
})
|
||||
}
|
||||
|
||||
/// Move every folder another device backs up into this machine's own
|
||||
/// device, so this machine carries on backing them up as itself. The other
|
||||
/// device stays registered, without those folders.
|
||||
///
|
||||
/// Device roots all live on the main volume, so each folder is one
|
||||
/// server-side move: nothing is downloaded or uploaded, and no quota is
|
||||
/// used twice. A folder whose name is taken here keeps its content under
|
||||
/// "<name> (<device>)". A failed folder does not stop the others, and a
|
||||
/// second run picks up whatever is still left on the other device.
|
||||
///
|
||||
/// Nothing is synced to this disk here. The moved folders are now this
|
||||
/// device's, so the ordinary restore offers them. Only folders that were
|
||||
/// already synced here from the other device are touched: their uid
|
||||
/// survives the move but their share does not.
|
||||
pub(crate) fn migrate_device(&self, uid: &str) -> CoreResult<String> {
|
||||
let source = self.restore_source(Some(uid))?;
|
||||
let target = self.ensure_device()?;
|
||||
if source.uid == target.uid {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{} is this computer's own device",
|
||||
source.name
|
||||
)));
|
||||
}
|
||||
let bad_root = |d: &StoredDevice| {
|
||||
parse_uid(&d.root_uid)
|
||||
.ok_or_else(|| CoreError::internal(format!("bad device root uid: {}", d.root_uid)))
|
||||
};
|
||||
let (source_root, target_root) = (bad_root(&source)?, bad_root(&target)?);
|
||||
if source_root.volume_id != target_root.volume_id {
|
||||
return Err(CoreError::invalid(
|
||||
"the two devices are on different Proton Drive volumes; copy it instead",
|
||||
));
|
||||
}
|
||||
|
||||
let mut taken: Vec<String> = self
|
||||
.device_root_nodes(&target_root)?
|
||||
.into_iter()
|
||||
.filter(|n| !n.trashed)
|
||||
.map(|n| n.name)
|
||||
.collect();
|
||||
let synced = self
|
||||
.db
|
||||
.sync_folder_list()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
|
||||
let mut moved = 0usize;
|
||||
let mut renamed: Vec<String> = Vec::new();
|
||||
let mut left: Vec<String> = Vec::new();
|
||||
let mut failed: Vec<String> = Vec::new();
|
||||
for node in self.device_root_nodes(&source_root)? {
|
||||
if node.trashed || node.name == PROFILE_DIR_NAME {
|
||||
continue;
|
||||
}
|
||||
// A device root only ever holds folders (B68); anything else was
|
||||
// put there by hand and is left for the user to deal with.
|
||||
if !node.is_folder() {
|
||||
left.push(node.name);
|
||||
continue;
|
||||
}
|
||||
let new_name = migrated_name(&node.name, &source.name, &taken);
|
||||
if let Err(e) = self.move_rename_remote(&node.uid, &target_root, new_name.as_deref()) {
|
||||
warn!(name = %node.name, error = %e, "migrate: could not move folder");
|
||||
failed.push(format!("{}: {e}", node.name));
|
||||
continue;
|
||||
}
|
||||
let landed = new_name.clone().unwrap_or_else(|| node.name.clone());
|
||||
if new_name.is_some() {
|
||||
renamed.push(format!("{} -> {landed}", node.name));
|
||||
}
|
||||
taken.push(landed);
|
||||
moved += 1;
|
||||
|
||||
let uid = node.uid.to_string();
|
||||
for folder in synced.iter().filter(|f| f.remote_uid == uid) {
|
||||
if let Err(e) = self.db.sync_folder_set_share(folder.id, &target.share_id) {
|
||||
warn!(id = folder.id, error = ?e, "migrate: could not re-point sync folder");
|
||||
}
|
||||
self.sync_now(Some(folder.id));
|
||||
}
|
||||
}
|
||||
self.invalidate_children_of(&source_root);
|
||||
self.invalidate_children_of(&target_root);
|
||||
|
||||
let mut message = format!("moved {moved} folder(s) from {}", source.name);
|
||||
if !renamed.is_empty() {
|
||||
message.push_str(&format!("; renamed: {}", renamed.join(", ")));
|
||||
}
|
||||
if !left.is_empty() {
|
||||
message.push_str(&format!("; left behind: {}", left.join(", ")));
|
||||
}
|
||||
if !failed.is_empty() {
|
||||
message.push_str(&format!("; failed: {}", failed.join(", ")));
|
||||
}
|
||||
info!(from = %source.name, to = %target.name, moved, failed = failed.len(),
|
||||
"device migrated");
|
||||
if moved == 0 && !failed.is_empty() {
|
||||
return Err(CoreError::conflict(message));
|
||||
}
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
// ---- device folder sync -------------------------
|
||||
|
||||
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
|
||||
@@ -277,7 +395,7 @@ impl Core {
|
||||
// any cached record, and recover an existing device by name.
|
||||
let remote = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_devices())
|
||||
.block_on(self.drive.enumerate_devices())
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
||||
|
||||
// An adopted uid is an explicit instruction, so it outranks everything
|
||||
@@ -336,7 +454,7 @@ impl Core {
|
||||
None => {
|
||||
let d = self
|
||||
.rt
|
||||
.block_on(self.client.create_device(&name, DeviceType::Linux))
|
||||
.block_on(self.drive.create_device(&name, DeviceType::Linux))
|
||||
.map_err(|e| CoreError::from_api(&e, "create device"))?;
|
||||
StoredDevice {
|
||||
uid: d.uid.to_string(),
|
||||
@@ -360,21 +478,25 @@ impl Core {
|
||||
root_uid: &NodeUid,
|
||||
name: &str,
|
||||
) -> CoreResult<Option<NodeUid>> {
|
||||
Ok(self
|
||||
.device_root_nodes(root_uid)?
|
||||
.into_iter()
|
||||
.find(|n| n.is_folder() && !n.trashed && n.name == name)
|
||||
.map(|n| n.uid))
|
||||
}
|
||||
|
||||
/// Everything directly under a device root, trashed nodes included.
|
||||
fn device_root_nodes(&self, root_uid: &NodeUid) -> CoreResult<Vec<Node>> {
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.drive.enumerate_folder_children_node_uids(root_uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
||||
if uids.is_empty() {
|
||||
return Ok(None);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
self.rt
|
||||
.block_on(self.drive.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?;
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
.find(|n| n.is_folder() && !n.trashed && n.name == name)
|
||||
.map(|n| n.uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))
|
||||
}
|
||||
|
||||
/// Add a local folder to this device's sync set: register the device if
|
||||
@@ -535,12 +657,16 @@ impl Core {
|
||||
} else {
|
||||
p.total.max(p.done) as u64
|
||||
},
|
||||
// Most passes find nothing; only one that queues work is
|
||||
// the drive syncing.
|
||||
background: true,
|
||||
},
|
||||
SyncPhase::Applying => JobItem {
|
||||
title: format!("Syncing {folder}"),
|
||||
detail: p.current.clone(),
|
||||
done: p.done as u64,
|
||||
total: p.total.max(p.done) as u64,
|
||||
background: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -842,6 +968,23 @@ impl Core {
|
||||
if folder.state != "idle" {
|
||||
return Err(SwitchBlocked::NotNow);
|
||||
}
|
||||
// `idle` only says the last pass applied everything it tried.
|
||||
// It never tries an ignored path, a symlink or a folder it
|
||||
// cannot read, it defers a file open for writing, and a file can
|
||||
// change after it ends. The eviction below deletes all of those
|
||||
// for good, so it waits until nothing here is missing from Drive
|
||||
// (B198).
|
||||
let baseline = self
|
||||
.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))
|
||||
{
|
||||
return Err(SwitchBlocked::Failed(format!(
|
||||
"going online-only would delete what is only on this computer: {reason}"
|
||||
)));
|
||||
}
|
||||
let root_uid = parse_uid(&folder.remote_uid).ok_or_else(|| {
|
||||
SwitchBlocked::Failed(format!("bad remote uid: {}", folder.remote_uid))
|
||||
})?;
|
||||
@@ -1107,10 +1250,38 @@ mod tests {
|
||||
use pdfs_core::db::StoredSyncFolder;
|
||||
|
||||
use super::{
|
||||
ApplicableMountMode, commit_after_teardown, pending_intent_matches,
|
||||
ApplicableMountMode, commit_after_teardown, migrated_name, pending_intent_matches,
|
||||
restore_snapshot_matches, select_mode_transition,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn migrated_folder_keeps_its_name_when_it_is_free() {
|
||||
let taken = vec!["Pictures".to_string()];
|
||||
assert_eq!(migrated_name("Documents", "DESKTOP-1", &taken), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrated_folder_is_named_after_its_device_on_a_clash() {
|
||||
let taken = vec!["Documents".to_string()];
|
||||
assert_eq!(
|
||||
migrated_name("Documents", "DESKTOP-1", &taken).as_deref(),
|
||||
Some("Documents (DESKTOP-1)")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrated_folder_is_numbered_when_the_device_name_is_taken_too() {
|
||||
let taken = vec![
|
||||
"Documents".to_string(),
|
||||
"Documents (DESKTOP-1)".to_string(),
|
||||
"Documents (DESKTOP-1) 2".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
migrated_name("Documents", "DESKTOP-1", &taken).as_deref(),
|
||||
Some("Documents (DESKTOP-1) 3")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_mode_cannot_select_a_destructive_transition() {
|
||||
let mut unmounts = 0;
|
||||
|
||||
@@ -347,17 +347,7 @@ impl Core {
|
||||
// its writer has to be noticed.
|
||||
self.sweep_parked_creates();
|
||||
}
|
||||
// Paused, nothing due changes that: sleep until a resume
|
||||
// wakes the worker (or the idle poll notices a timed pause
|
||||
// ran out), not until the next op falls due — that is
|
||||
// "now" for everything already queued.
|
||||
if paused {
|
||||
self.wait_for_drain_work();
|
||||
continue;
|
||||
}
|
||||
// A debounced or backed-off op may be waiting: sleep only
|
||||
// until it becomes due rather than the full idle-poll.
|
||||
self.wait_for_drain_work_or_due();
|
||||
self.wait_for_drain_work_or_due(online && !paused);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -732,14 +722,16 @@ impl Core {
|
||||
/// Like [`wait_for_drain_work`](Self::wait_for_drain_work), but first
|
||||
/// checks whether a debounced or backed-off op is due before the idle-poll
|
||||
/// ceiling. A 2-second debounce must not sleep 30 seconds.
|
||||
fn wait_for_drain_work_or_due(&self) {
|
||||
let timeout = match self.db.earliest_due_at() {
|
||||
Ok(Some(ts)) => {
|
||||
let remaining = (ts - now_millis()).max(0) as u64;
|
||||
Duration::from_millis(remaining).min(DRAIN_IDLE_POLL)
|
||||
}
|
||||
_ => DRAIN_IDLE_POLL,
|
||||
///
|
||||
/// `claiming` is false while the worker claims nothing (offline or
|
||||
/// paused); see [`idle_wait`].
|
||||
fn wait_for_drain_work_or_due(&self, claiming: bool) {
|
||||
let earliest = if claiming {
|
||||
self.db.earliest_due_at().ok().flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let timeout = idle_wait(claiming, earliest, now_millis());
|
||||
sleep_for_drain_work(&self.drain_wake, || self.shutdown.is_stopping(), timeout);
|
||||
}
|
||||
|
||||
@@ -2678,7 +2670,9 @@ fn proton_error<'e>(e: &'e (dyn std::error::Error + 'static)) -> Option<&'e Prot
|
||||
/// is, or `Some(None)` for a failure that is not a refusal.
|
||||
fn refusal(e: &(dyn std::error::Error + 'static)) -> Option<Option<SyncIssue>> {
|
||||
let proton = proton_error(e)?;
|
||||
if is_network_error(proton) {
|
||||
// A refused session refuses everything, so it says nothing about this
|
||||
// op either; the mount is offline for it (`Core::lost_link`).
|
||||
if is_network_error(proton) || pdfs_core::error::session_revoked(proton) {
|
||||
return None;
|
||||
}
|
||||
let ProtonError::Api(api) = proton else {
|
||||
@@ -2833,6 +2827,23 @@ fn sleep_for_drain_work(
|
||||
*woken = false;
|
||||
}
|
||||
|
||||
/// How long an idle drain worker sleeps: until the earliest queued op falls
|
||||
/// due, at most the idle poll.
|
||||
///
|
||||
/// A worker that claims nothing — offline or paused — sleeps the full idle
|
||||
/// poll whatever is due. Every op already queued is due "now" for it, so
|
||||
/// sleeping until then was a zero wait, and all the workers spun on the queue
|
||||
/// for as long as the outage lasted (`docs/BUGS.md` B200). A reconnect or a
|
||||
/// resume wakes the drain, so nothing waits the full poll for it.
|
||||
fn idle_wait(claiming: bool, earliest_due: Option<i64>, now: i64) -> Duration {
|
||||
match earliest_due {
|
||||
Some(ts) if claiming => {
|
||||
Duration::from_millis((ts - now).max(0) as u64).min(DRAIN_IDLE_POLL)
|
||||
}
|
||||
_ => DRAIN_IDLE_POLL,
|
||||
}
|
||||
}
|
||||
|
||||
fn placed_in(st: &crate::state::State, uid: &NodeUid) -> Option<(String, Option<NodeUid>)> {
|
||||
let entry = st.by_uid.get(uid).and_then(|ino| st.entries.get(ino))?;
|
||||
Some((entry.node.name.clone(), entry.node.parent_uid.clone()))
|
||||
@@ -2858,6 +2869,23 @@ mod tests {
|
||||
assert!(started.elapsed() < Duration::from_secs(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_offline_worker_waits_the_idle_poll_for_a_past_due_op() {
|
||||
// The op fell due long ago, and offline nobody attempts it, so it
|
||||
// stays due: waiting for it was a zero wait, forever (B200).
|
||||
assert_eq!(idle_wait(false, Some(0), 10_000), DRAIN_IDLE_POLL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_claiming_worker_waits_only_until_the_next_op_falls_due() {
|
||||
assert_eq!(
|
||||
idle_wait(true, Some(12_000), 10_000),
|
||||
Duration::from_secs(2)
|
||||
);
|
||||
assert_eq!(idle_wait(true, Some(0), 10_000), Duration::ZERO);
|
||||
assert_eq!(idle_wait(true, None, 10_000), DRAIN_IDLE_POLL);
|
||||
}
|
||||
|
||||
fn pending(kind: &str) -> PendingOp {
|
||||
PendingOp {
|
||||
id: 0,
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
//! real client implements it by forwarding, and the simulation tests implement
|
||||
//! it with an in-memory Drive that injects Drive's known faults.
|
||||
//!
|
||||
//! Sharing, Photos, devices, invitations and public links stay on the concrete
|
||||
//! client (`Core::client`) until a test needs them faked.
|
||||
//! Sharing, Photos, invitations, public links and the rest of the device calls
|
||||
//! stay on the concrete client (`Core::client`) until a test needs them faked.
|
||||
//! Listing and registering devices are here, so a simulated daemon can sync a
|
||||
//! local folder.
|
||||
//!
|
||||
//! The method names match the client's own, so a call site reads the same
|
||||
//! whichever it goes through. The client's generic methods (a reader or writer
|
||||
@@ -26,7 +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::{
|
||||
DriveEvent, DriveEventScopeId, Node, NodeMoveItem, ProtonDriveClient, RevisionReader, Thumbnail,
|
||||
Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem, ProtonDriveClient,
|
||||
RevisionReader, Thumbnail,
|
||||
};
|
||||
|
||||
/// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`].
|
||||
@@ -154,6 +157,12 @@ pub(crate) trait DriveApi: Send + Sync {
|
||||
/// The account's storage, across every Proton product.
|
||||
async fn quota(&self) -> Result<Quota>;
|
||||
|
||||
/// Every computer that backs up to the account.
|
||||
async fn enumerate_devices(&self) -> Result<Vec<Device>>;
|
||||
|
||||
/// Register a computer, with a root folder of its own named `name`.
|
||||
async fn create_device(&self, name: &str, device_type: DeviceType) -> Result<Device>;
|
||||
|
||||
/// Called by the drain once it has let go of the blob of an upload to
|
||||
/// `uid` that landed, or retired the create that made `uid`, before the
|
||||
/// tree has the node, so a simulation can hold it there. Blocks the drain
|
||||
@@ -470,4 +479,12 @@ impl DriveApi for ProtonDriveClient {
|
||||
async fn quota(&self) -> Result<Quota> {
|
||||
ProtonDriveClient::quota(self).await
|
||||
}
|
||||
|
||||
async fn enumerate_devices(&self) -> Result<Vec<Device>> {
|
||||
ProtonDriveClient::enumerate_devices(self).await
|
||||
}
|
||||
|
||||
async fn create_device(&self, name: &str, device_type: DeviceType) -> Result<Device> {
|
||||
ProtonDriveClient::create_device(self, name, device_type).await
|
||||
}
|
||||
}
|
||||
@@ -235,6 +235,8 @@ fn status_keys(status: &CtlResponse) -> (String, String) {
|
||||
used,
|
||||
budget,
|
||||
online,
|
||||
session_expired,
|
||||
tokens_unsaved,
|
||||
pending_uploads,
|
||||
pending_changes,
|
||||
..
|
||||
@@ -247,7 +249,7 @@ fn status_keys(status: &CtlResponse) -> (String, String) {
|
||||
);
|
||||
let key = format!(
|
||||
"{queue}/{staged_bytes}/{paused}/{paused_until:?}/{username}/{mountpoint}/{pinned}/\
|
||||
{used}/{budget}/{online}"
|
||||
{used}/{budget}/{online}/{session_expired}/{tokens_unsaved}"
|
||||
);
|
||||
(key, queue)
|
||||
}
|
||||
@@ -325,6 +327,9 @@ mod tests {
|
||||
budget: 0,
|
||||
pins: Vec::new(),
|
||||
online: true,
|
||||
session_expired: false,
|
||||
tokens_unsaved: false,
|
||||
version: "3.3.0".into(),
|
||||
pending_uploads: 1,
|
||||
pending_changes: 0,
|
||||
};
|
||||
|
||||
@@ -1267,8 +1267,12 @@ impl ProtonFs {
|
||||
//
|
||||
// Complete pending blobs are the common case and can be copied without
|
||||
// any network access. An incomplete blob still has gaps referring to
|
||||
// the remote base; stacking another write on it cannot be represented
|
||||
// safely by WriteHandle, so fail the open rather than risk corruption.
|
||||
// the remote base, and stacking another write on it cannot be
|
||||
// represented by WriteHandle, so the copy's gaps are filled from that
|
||||
// base first, as the drain fills them before an upload. Only when that
|
||||
// fails (offline, or the base is gone) does the open fail: refusing
|
||||
// every such open failed a create over a file whose partial edit had
|
||||
// not drained yet (`docs/BUGS.md` B204).
|
||||
//
|
||||
// Reading that blob and installing the handle cannot be done under one
|
||||
// lock — the copy is the size of the file — so the base is re-checked at
|
||||
@@ -1302,10 +1306,6 @@ impl ProtonFs {
|
||||
}
|
||||
}
|
||||
let pending_base = self.core.pending.lock().get(&uid).cloned();
|
||||
if pending_base.as_ref().is_some_and(|p| !p.meta.complete) {
|
||||
error!(%uid, "refusing write over incomplete queued revision");
|
||||
break 'install Err(Errno::EIO);
|
||||
}
|
||||
let (file, path) = match self.core.cache.create_scratch() {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
@@ -1325,6 +1325,25 @@ impl ProtonFs {
|
||||
"copy queued revision into write scratch failed");
|
||||
break 'install Err(Errno::EIO);
|
||||
}
|
||||
if !pending.meta.complete {
|
||||
let mut authored = Intervals::default();
|
||||
for &(s, e) in &pending.meta.authored {
|
||||
authored.add(s, e);
|
||||
}
|
||||
if let Err(error) = self.core.fill_gaps(
|
||||
&uid,
|
||||
&file,
|
||||
pending.meta.len,
|
||||
pending.meta.base_mtime,
|
||||
pending.meta.base_size,
|
||||
&authored,
|
||||
) {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
error!(%uid, ?error, "cannot complete the queued revision a write open is based on");
|
||||
break 'install Err(error);
|
||||
}
|
||||
debug!(%uid, "write open completed an incomplete queued revision");
|
||||
}
|
||||
initial_written.add(0, pending.meta.len);
|
||||
}
|
||||
// Sampled *before* the state lock, never under it: no site in
|
||||
|
||||
+117
-35
@@ -744,6 +744,9 @@ struct Core {
|
||||
/// uploading that same tree — the engine would upload files as they vanish
|
||||
/// and then walk the FUSE mount as if it were local.
|
||||
sync_locks: Arc<Mutex<HashMap<i64, Arc<Mutex<()>>>>>,
|
||||
/// Per-sync-folder paths the last pass could not read, so the activity feed
|
||||
/// says so when the set changes rather than on every poll (B196).
|
||||
sync_unscanned: Arc<Mutex<HashMap<i64, Vec<String>>>>,
|
||||
/// Nodes this daemon changed on the remote itself, and how many echoes of
|
||||
/// those changes the event feed still owes us.
|
||||
///
|
||||
@@ -1031,7 +1034,9 @@ impl Core {
|
||||
|
||||
let core = self.clone();
|
||||
std::thread::spawn(move || {
|
||||
let job = core.transfers.begin_job("Checking the cache database");
|
||||
let job = core
|
||||
.transfers
|
||||
.begin_background_job("Checking the cache database");
|
||||
job.detail("Verifying every page");
|
||||
let problems = match core.db.integrity_check() {
|
||||
Ok(problems) => problems,
|
||||
@@ -4222,8 +4227,10 @@ impl Core {
|
||||
}
|
||||
|
||||
/// Pin the node at mountpoint-relative `rel`. A file downloads its full
|
||||
/// plaintext into the content cache; a folder records a recursive pin and
|
||||
/// downloads every descendant file (selective sync). Returns a human message.
|
||||
/// plaintext into the content cache before this returns. A folder records a
|
||||
/// recursive pin and downloads every descendant file on a background thread,
|
||||
/// reported as a job: a big folder far outlasts the socket read timeout.
|
||||
/// Returns a human message.
|
||||
fn pin(&self, rel: &Path) -> CoreResult<String> {
|
||||
let (ino, uid) = self.resolve(rel)?;
|
||||
let (name, is_folder, mtime, size) = {
|
||||
@@ -4240,34 +4247,93 @@ impl Core {
|
||||
)
|
||||
};
|
||||
if is_folder {
|
||||
// Offline, every download would fail and the folder would only look
|
||||
// kept. Refuse instead, as a file pin does.
|
||||
if !self.is_online() {
|
||||
return Err(CoreError::offline());
|
||||
}
|
||||
// Record the recursive pin first so every descendant is eviction-
|
||||
// exempt before we start filling the cache with the subtree.
|
||||
self.cache
|
||||
.add_pin(&uid, rel, true)
|
||||
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
||||
let n = self.pin_subtree(ino)?;
|
||||
return Ok(format!("{name} ({n} files)"));
|
||||
let core = self.clone();
|
||||
let folder = name.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pin-folder".into())
|
||||
.spawn(move || core.pin_subtree(ino, &uid, &folder))
|
||||
.map_err(|e| CoreError::internal(format!("spawn pin: {e}")))?;
|
||||
return Ok(format!("{name}: downloading its files in the background"));
|
||||
}
|
||||
let bytes = self
|
||||
.download_file_tracked(&uid, &name, size)
|
||||
.map_err(|e| CoreError::from_api(&e, "download"))?;
|
||||
self.cache
|
||||
.store(&uid, mtime, size, &bytes)
|
||||
.map_err(|e| CoreError::internal(format!("cache store: {e}")))?;
|
||||
self.fetch_content(&uid, &name, mtime, size)?;
|
||||
self.cache
|
||||
.add_pin(&uid, rel, false)
|
||||
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Download and cache every file in the subtree rooted at folder `ino`,
|
||||
/// returning the count cached (already-fresh blobs counted, not re-fetched).
|
||||
/// Walks the tree depth-first, enumerating each folder so a cold subtree is
|
||||
/// fully discovered; the lock is dropped before each network download.
|
||||
fn pin_subtree(&self, ino: u64) -> CoreResult<usize> {
|
||||
/// Download and cache every file in the subtree rooted at the pinned folder
|
||||
/// `ino` (`folder`, named `name`), as a job the front ends show. Walks the
|
||||
/// tree depth-first, enumerating each folder so a cold subtree is fully
|
||||
/// discovered; the lock is dropped before each network download. Stops when
|
||||
/// the folder is unpinned or the daemon stops.
|
||||
fn pin_subtree(&self, ino: u64, folder: &NodeUid, name: &str) {
|
||||
let job = self
|
||||
.transfers
|
||||
.begin_job(format!("Making {name} available offline"));
|
||||
match self.pin_subtree_files(ino) {
|
||||
Ok(files) => {
|
||||
job.set_total(files.len() as u64);
|
||||
let mut failed = 0usize;
|
||||
for (uid, file, mtime, size) in files {
|
||||
if self.shutdown.is_stopping() || !self.cache.is_pinned(folder) {
|
||||
return;
|
||||
}
|
||||
job.detail(file.clone());
|
||||
if let Err(e) = self.fetch_pinned(&uid, &file, mtime, size) {
|
||||
warn!(%uid, error = %e, "pin folder: download failed");
|
||||
failed += 1;
|
||||
}
|
||||
job.step();
|
||||
}
|
||||
info!(folder = %name, failed, "pin folder: done");
|
||||
}
|
||||
Err(e) => warn!(folder = %name, error = %e, "pin folder: enumerate failed"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache the file `uid` for a folder pin. Its `mtime` and `size` were read
|
||||
/// before the download, and a create landing or a remote edit can change
|
||||
/// them while it runs. The blob is then stored under the old ones and reads
|
||||
/// as stale, so the file is listed as kept but not available offline. Fetch
|
||||
/// it again under the new ones, a few times at most.
|
||||
fn fetch_pinned(&self, uid: &NodeUid, name: &str, mtime: i64, size: u64) -> CoreResult<()> {
|
||||
let mut fetched = (mtime, size);
|
||||
for _ in 0..3 {
|
||||
self.fetch_content(uid, name, fetched.0, fetched.1)?;
|
||||
let current = {
|
||||
let st = self.state();
|
||||
st.by_uid
|
||||
.get(uid)
|
||||
.and_then(|ino| st.entries.get(ino))
|
||||
.map(|e| (e.node.modification_time, node_size(&e.node)))
|
||||
};
|
||||
match current {
|
||||
Some(now) if now != fetched => fetched = now,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every file in the subtree rooted at folder `ino`.
|
||||
fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<(NodeUid, String, i64, u64)>> {
|
||||
let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new();
|
||||
let mut stack = vec![ino];
|
||||
while let Some(dir) = stack.pop() {
|
||||
if self.shutdown.is_stopping() {
|
||||
break;
|
||||
}
|
||||
self.ensure_children(dir)
|
||||
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
||||
let st = self.state();
|
||||
@@ -4288,22 +4354,7 @@ impl Core {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut count = 0;
|
||||
for (uid, name, mtime, size) in files {
|
||||
if self.cache.is_cached(&uid, mtime, size) {
|
||||
count += 1;
|
||||
continue;
|
||||
}
|
||||
match self.download_file_tracked(&uid, &name, size) {
|
||||
Ok(bytes) => {
|
||||
if self.cache.store(&uid, mtime, size, &bytes).is_ok() {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(%uid, error = %e, "pin subtree: download failed"),
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Fetch a thumbnail of `ttype` for the file at `ino`, served from the cache
|
||||
@@ -4349,6 +4400,8 @@ impl Core {
|
||||
|
||||
/// Unpin the node at `rel`, evicting its cached content. For a folder, also
|
||||
/// evicts every descendant's cached blob (the subtree is no longer kept).
|
||||
/// A node kept only because a folder above it is pinned is refused: there
|
||||
/// is no pin of its own to remove, and it would stay kept all the same.
|
||||
fn unpin(&self, rel: &Path) -> CoreResult<String> {
|
||||
let (ino, uid) = self.resolve(rel)?;
|
||||
let (name, is_folder) = {
|
||||
@@ -4358,14 +4411,24 @@ impl Core {
|
||||
.map(|e| (e.node.name.clone(), e.node.is_folder()))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if self.pinned_by_folder(ino) {
|
||||
return Err(CoreError::conflict(format!(
|
||||
"{name} is kept offline because a folder above it is; unpin that folder instead"
|
||||
)));
|
||||
}
|
||||
self.cache
|
||||
.remove_pin(&uid)
|
||||
.map_err(|e| CoreError::from_api(&e, "unpin"))?;
|
||||
// A recursively-pinned folder's descendants were eviction-exempt; now
|
||||
// that the pin is gone, reclaim their blobs eagerly instead of waiting
|
||||
// for budget pressure. Descendants come from the DB node tree.
|
||||
// for budget pressure. Descendants come from the DB node tree. One
|
||||
// pinned on its own, or under a pinned subfolder, is still kept.
|
||||
if is_folder && let Ok(uids) = self.db.descendants(&uid.to_string()) {
|
||||
let still_pinned = self.pinned_set();
|
||||
for s in uids {
|
||||
if still_pinned.contains(&s) {
|
||||
continue;
|
||||
}
|
||||
if let Some(u) = parse_uid(&s) {
|
||||
self.cache.evict(&u);
|
||||
self.evict_reader(&u);
|
||||
@@ -4375,6 +4438,20 @@ impl Core {
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Whether the node at `ino` is kept offline because a folder above it is
|
||||
/// pinned, whether or not it is also pinned on its own.
|
||||
fn pinned_by_folder(&self, ino: u64) -> bool {
|
||||
let parent = {
|
||||
let st = self.state();
|
||||
st.entries
|
||||
.get(&ino)
|
||||
.filter(|e| e.parent != ino)
|
||||
.and_then(|e| st.entries.get(&e.parent))
|
||||
.map(|p| p.uid.clone())
|
||||
};
|
||||
parent.is_some_and(|uid| self.cache.is_pinned(&uid))
|
||||
}
|
||||
|
||||
/// A Photos API handle sharing this Core's Drive client and session, so it
|
||||
/// reuses the daemon's single authenticated session rather than logging in
|
||||
/// again (Proton refresh tokens are single-use). Cheap — the Drive client
|
||||
@@ -4387,10 +4464,13 @@ impl Core {
|
||||
/// the same lazy remote enumeration `readdir` uses, projected into
|
||||
/// serializable [`DirEntry`]s (with per-file pin state).
|
||||
fn list_dir(&self, rel: &Path) -> CoreResult<Vec<DirEntry>> {
|
||||
let (ino, _uid) = self.resolve(rel)?;
|
||||
let (ino, uid) = self.resolve(rel)?;
|
||||
self.ensure_children(ino)
|
||||
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
||||
self.settle_listing_sizes(ino);
|
||||
// Every entry shares this folder as its parent, so one lookup says
|
||||
// whether they are all kept through it.
|
||||
let pinned_by_folder = self.cache.is_pinned(&uid);
|
||||
// Snapshot the listing, then drop the lock before touching the on-disk
|
||||
// pin registry so a slow disk read doesn't block FUSE metadata ops.
|
||||
let rows: Vec<(String, bool, u64, i64, NodeUid, u64)> = {
|
||||
@@ -4422,7 +4502,8 @@ impl Core {
|
||||
is_dir,
|
||||
size,
|
||||
modified,
|
||||
pinned: self.cache.is_pinned(&uid),
|
||||
pinned: pinned_by_folder || self.cache.is_pinned(&uid),
|
||||
pinned_by_folder,
|
||||
cached: !is_dir && self.cache.is_cached(&uid, modified, size),
|
||||
uid: uid.to_string(),
|
||||
// Listing entries live in the requested dir; the caller derives
|
||||
@@ -5314,6 +5395,7 @@ impl Core {
|
||||
// A trashed node can't be pinned or served from the mount, so its
|
||||
// content is never current cache: report neither.
|
||||
pinned: false,
|
||||
pinned_by_folder: false,
|
||||
cached: false,
|
||||
uid: item.uid,
|
||||
path: String::new(),
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
//! queued path, and the probe thread here flips it back when Proton answers.
|
||||
|
||||
use std::future::Future;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use parking_lot::{Condvar, Mutex};
|
||||
use pdfs_core::error::session_revoked;
|
||||
use proton_drive_rs::proton_sdk::api::ResponseCode;
|
||||
use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError};
|
||||
use tracing::{debug, info, warn};
|
||||
@@ -54,6 +55,26 @@ pub(crate) struct Link {
|
||||
changed: Condvar,
|
||||
/// Folders listed from the DB alone while offline, to relist once back.
|
||||
pub(crate) stale_listings: Mutex<std::collections::HashSet<u64>>,
|
||||
/// Proton refused the session itself ([`session_revoked`]). The mount is
|
||||
/// offline then, but no probe brings it back: only a new login does, and
|
||||
/// that restarts the daemon. Read by front-ends through `Response::Status`,
|
||||
/// so they can ask the user to sign in again rather than say "offline".
|
||||
pub(crate) session_expired: AtomicBool,
|
||||
/// Proton refused the session, but only because another process rotated
|
||||
/// it (`pdfs unlock`): the keyring holds the live tokens. The mount loop
|
||||
/// ends the mount for the daemon to mount again from the keyring.
|
||||
pub(crate) resume_stored: AtomicBool,
|
||||
}
|
||||
|
||||
impl Link {
|
||||
/// The link of a mount that comes up with Proton refusing its session, or
|
||||
/// not.
|
||||
pub(crate) fn starting(session_expired: bool) -> Self {
|
||||
Self {
|
||||
session_expired: AtomicBool::new(session_expired),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a failed call failed because Proton could not be reached, rather
|
||||
@@ -161,10 +182,20 @@ impl Core {
|
||||
self.rt.block_on(bounded_by(limit, call))
|
||||
}
|
||||
|
||||
/// Whether Proton refused the session itself, so nothing reaches it until
|
||||
/// the user signs in again.
|
||||
pub(crate) fn session_expired(&self) -> bool {
|
||||
self.link.session_expired.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Report a failed remote call. Returns whether it failed for want of a
|
||||
/// network, in which case the mount is now offline and the caller should
|
||||
/// take its queued path instead of failing.
|
||||
/// network, or of a session, in which case the mount is now offline and the
|
||||
/// caller should take its queued path instead of failing.
|
||||
pub(crate) fn lost_link(&self, e: &ProtonError, what: &str) -> bool {
|
||||
if session_revoked(e) {
|
||||
self.mark_session_expired(what, e);
|
||||
return true;
|
||||
}
|
||||
if !is_network_error(e) {
|
||||
return false;
|
||||
}
|
||||
@@ -172,6 +203,29 @@ impl Core {
|
||||
true
|
||||
}
|
||||
|
||||
/// Flip the mount offline for good: Proton refused the session, and only a
|
||||
/// new login brings it back. Changes stay queued for the daemon that login
|
||||
/// starts. Only the first caller logs it.
|
||||
///
|
||||
/// Unless the keyring holds a newer session than the refused one: then
|
||||
/// the mount ends, for the daemon to mount again on that one.
|
||||
pub(crate) fn mark_session_expired(&self, what: &str, error: &dyn std::fmt::Display) {
|
||||
if self.link.resume_stored.load(Ordering::Relaxed) {
|
||||
return self.mark_offline(what, error);
|
||||
}
|
||||
if pdfs_core::auth::stored_session_is_newer() {
|
||||
info!(during = what, %error,
|
||||
"Proton refused a session another process has since refreshed; resuming it");
|
||||
self.link.resume_stored.store(true, Ordering::Relaxed);
|
||||
return self.mark_offline(what, error);
|
||||
}
|
||||
if !self.link.session_expired.swap(true, Ordering::Relaxed) {
|
||||
warn!(during = what, %error,
|
||||
"Proton refused the session; run `pdfs login` to sign in again");
|
||||
}
|
||||
self.mark_offline(what, error);
|
||||
}
|
||||
|
||||
/// Flip the mount offline and wake the probe. Only the first caller of an
|
||||
/// outage logs it; the rest are the same outage.
|
||||
pub(crate) fn mark_offline(&self, what: &str, error: &dyn std::fmt::Display) {
|
||||
@@ -276,6 +330,9 @@ impl Core {
|
||||
|
||||
/// Probe until Proton answers, then flip the mount online. Returns `false`
|
||||
/// if the daemon is stopping.
|
||||
///
|
||||
/// A refused session is not probed: every probe would be refused the same
|
||||
/// way, forever (issue #35). The thread waits for the stop instead.
|
||||
fn probe_until_online(&self) -> bool {
|
||||
let started = self.link.lost_at.lock().unwrap_or_else(Instant::now);
|
||||
let mut delay = if started.elapsed() < YOUNG_OUTAGE {
|
||||
@@ -287,6 +344,10 @@ impl Core {
|
||||
if !self.shutdown.sleep(delay) {
|
||||
return false;
|
||||
}
|
||||
if self.session_expired() {
|
||||
delay = ONLINE_PROBE_MAX;
|
||||
continue;
|
||||
}
|
||||
let probe = self.rt.block_on(async {
|
||||
match tokio::time::timeout(PROBE_TIMEOUT, self.drive.get_my_files_folder()).await {
|
||||
Ok(result) => result,
|
||||
@@ -308,6 +369,7 @@ impl Core {
|
||||
self.mark_online();
|
||||
return true;
|
||||
}
|
||||
Err(e) if session_revoked(&e) => self.mark_session_expired("online probe", &e),
|
||||
Err(e) => {
|
||||
debug!(error = %e, ?delay, "online probe failed; still offline");
|
||||
delay = next_probe_delay(delay, started.elapsed());
|
||||
|
||||
@@ -12,6 +12,10 @@ pub enum MountOutcome {
|
||||
/// The kernel mount ended on its own (e.g. an external `fusermount -u`).
|
||||
/// The caller may want to remount.
|
||||
Unmounted,
|
||||
/// Proton refused the session, and the keyring holds a newer one: another
|
||||
/// process (`pdfs unlock`) refreshed it, or a new login replaced it. We
|
||||
/// unmounted ourselves; the caller should mount again, which resumes it.
|
||||
ResumeStored,
|
||||
}
|
||||
|
||||
/// Whether `path` is a mountpoint whose FUSE connection is dead — the state a
|
||||
@@ -210,30 +214,60 @@ fn fetch_or_recall_root(
|
||||
drive: &dyn DriveApi,
|
||||
rt: &tokio::runtime::Handle,
|
||||
db: &Db,
|
||||
) -> std::io::Result<(Node, bool)> {
|
||||
) -> std::io::Result<(Node, Start)> {
|
||||
let err = match rt.block_on(drive.get_my_files_folder()) {
|
||||
Ok(root) => {
|
||||
if let Err(e) = db.set_state_str(ROOT_UID_KEY, &root.uid.to_string()) {
|
||||
warn!(error = %e, "persist root uid failed");
|
||||
}
|
||||
return Ok((root, true));
|
||||
return Ok((root, Start::Online));
|
||||
}
|
||||
Err(e) => e,
|
||||
};
|
||||
// Still mounted from the cache, so cached files stay readable and changes
|
||||
// queue for the daemon the next login starts.
|
||||
let start = if pdfs_core::error::session_revoked(&err) {
|
||||
Start::SessionExpired
|
||||
} else {
|
||||
Start::Offline
|
||||
};
|
||||
if start == Start::SessionExpired && pdfs_core::auth::stored_session_is_newer() {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"Proton refused a session another process has since refreshed ({err}); resuming it"
|
||||
)));
|
||||
}
|
||||
let cached = db
|
||||
.state_str(ROOT_UID_KEY)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|uid| db.node_by_uid(&uid).ok().flatten());
|
||||
match cached {
|
||||
Some(root) if start == Start::SessionExpired => {
|
||||
warn!(error = %err,
|
||||
"Proton refused the session; mounting from cache until `pdfs login`");
|
||||
Ok((root, start))
|
||||
}
|
||||
Some(root) => {
|
||||
warn!(error = %err, "fetch My Files root failed; mounting from cache (offline)");
|
||||
Ok((root, false))
|
||||
Ok((root, start))
|
||||
}
|
||||
None if start == Start::SessionExpired => Err(std::io::Error::other(format!(
|
||||
"Proton refused the session ({err}); run `pdfs login`"
|
||||
))),
|
||||
None => Err(std::io::Error::other(format!("fetch My Files root: {err}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// How a mount comes up, from what fetching its root said.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Start {
|
||||
Online,
|
||||
/// Proton could not be reached: serve the cache and probe for the link.
|
||||
Offline,
|
||||
/// Proton refused the session: serve the cache until a new login.
|
||||
SessionExpired,
|
||||
}
|
||||
|
||||
/// Per-mount settings resolved from [`pdfs_core::config::AppConfig`] by the
|
||||
/// caller, so `mount` itself never reads config or the environment.
|
||||
pub struct MountOptions {
|
||||
@@ -393,7 +427,8 @@ pub(crate) fn mount_with(
|
||||
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
|
||||
// so the age it reports is the daemon's own.
|
||||
super::diagnostics::start_clock();
|
||||
let (root, online) = fetch_or_recall_root(drive.as_ref(), &rt, &db)?;
|
||||
let (root, start) = fetch_or_recall_root(drive.as_ref(), &rt, &db)?;
|
||||
let online = start == Start::Online;
|
||||
let scope = root.tree_event_scope_id();
|
||||
db.mount_upsert_my_files(&mountpoint.to_string_lossy(), &root.uid.to_string(), None)
|
||||
.map_err(|error| std::io::Error::other(format!("project My Files mount: {error}")))?;
|
||||
@@ -429,7 +464,7 @@ pub(crate) fn mount_with(
|
||||
shared_generation: Arc::new(AtomicU64::new(0)),
|
||||
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
||||
online: Arc::new(AtomicBool::new(online)),
|
||||
link: Arc::new(Default::default()),
|
||||
link: Arc::new(link::Link::starting(start == Start::SessionExpired)),
|
||||
sync_paused_until: Arc::new(AtomicI64::new(paused_until)),
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
hidden: Arc::new(Mutex::new(HashSet::new())),
|
||||
@@ -475,6 +510,7 @@ pub(crate) fn mount_with(
|
||||
sync_tx,
|
||||
mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
|
||||
sync_locks: Arc::new(Mutex::new(HashMap::new())),
|
||||
sync_unscanned: Arc::new(Mutex::new(HashMap::new())),
|
||||
states: Arc::new(StateRegistry::default()),
|
||||
};
|
||||
// Before anything can queue work against it: the drain thread below reaches
|
||||
@@ -718,6 +754,18 @@ pub(crate) fn mount_with(
|
||||
}
|
||||
break MountOutcome::Shutdown;
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
|
||||
if core.link.resume_stored.load(Ordering::Relaxed) =>
|
||||
{
|
||||
info!("unmounting to resume the stored session");
|
||||
core.mounts.lock().close();
|
||||
if let Err(e) = teardown_session(&core.session_live, || {
|
||||
umount_session_unblocked(bg, main_conn)
|
||||
}) {
|
||||
warn!(error = %e, "umount_and_join failed");
|
||||
}
|
||||
break MountOutcome::ResumeStored;
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
if bg.guard.is_finished() {
|
||||
info!("mount ended externally");
|
||||
|
||||
@@ -25,6 +25,7 @@ use proton_drive_rs::NodeKind;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::state::State;
|
||||
use super::sync::mirror_subtree_unsynced;
|
||||
use super::{Core, is_local_uid, parse_uid};
|
||||
|
||||
@@ -130,6 +131,24 @@ 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 {
|
||||
for _ in 0..MAX_TREE_DEPTH {
|
||||
if ino == root {
|
||||
return true;
|
||||
}
|
||||
match st.entries.get(&ino) {
|
||||
Some(entry) if entry.parent != ino => ino = entry.parent,
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// How deep [`ino_is_under`] walks before it gives up.
|
||||
const MAX_TREE_DEPTH: usize = 1024;
|
||||
|
||||
/// Whether `uid` is `root` or lies under it, as far as the nodes table knows.
|
||||
fn under(core: &Core, root: &NodeUid, uid: &str) -> bool {
|
||||
let root = root.to_string();
|
||||
@@ -336,7 +355,7 @@ impl Core {
|
||||
core.require_uid_writable(authority)
|
||||
.map_err(|e| core.errno_error(e, "move access"))?;
|
||||
}
|
||||
if self.has_pending_work_under(&uid)? {
|
||||
if self.has_pending_work_under(ino, &uid)? {
|
||||
return Err(CoreError::conflict(
|
||||
"it has changes that are not uploaded yet; wait for the upload to finish",
|
||||
));
|
||||
@@ -410,10 +429,11 @@ impl Core {
|
||||
Ok(uid)
|
||||
}
|
||||
|
||||
/// Whether the queue owes Drive anything for `uid` or a node under it, or a
|
||||
/// file under it is open for writing. Moving such a node away would strand
|
||||
/// the pending change against a path that no longer leads to it.
|
||||
fn has_pending_work_under(&self, uid: &NodeUid) -> CoreResult<bool> {
|
||||
/// Whether the queue owes Drive anything for `uid` (inode `ino`) or a node
|
||||
/// under it, or a file under it is open for writing. Moving such a node away
|
||||
/// would strand the pending change against a path that no longer leads to
|
||||
/// it.
|
||||
fn has_pending_work_under(&self, ino: u64, uid: &NodeUid) -> CoreResult<bool> {
|
||||
let ops = self
|
||||
.db
|
||||
.pending_ops()
|
||||
@@ -427,12 +447,18 @@ impl Core {
|
||||
}) {
|
||||
return Ok(true);
|
||||
}
|
||||
// Collected first so no State lock is held across the nodes-table reads.
|
||||
let mut writing = Vec::new();
|
||||
// Open handles are placed in the tree by inode, not by uid in the nodes
|
||||
// table. A file opened just after its create landed holds the
|
||||
// placeholder uid until the tree catches up, and the nodes table no
|
||||
// longer has that uid, so the move went ahead under an open write.
|
||||
let mut writing = false;
|
||||
self.for_each_state(|st| {
|
||||
writing.extend(st.active_writes.values().map(|w| w.uid.to_string()));
|
||||
writing = st
|
||||
.active_writes
|
||||
.keys()
|
||||
.any(|&open| ino_is_under(st, open, ino));
|
||||
});
|
||||
Ok(writing.iter().any(|w| under(self, uid, w)))
|
||||
Ok(writing)
|
||||
}
|
||||
|
||||
/// Bring the source location level with a move Drive has made.
|
||||
|
||||
@@ -66,6 +66,7 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
||||
size,
|
||||
modified: n.modification_time,
|
||||
pinned: false,
|
||||
pinned_by_folder: false,
|
||||
cached: false,
|
||||
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
|
||||
uid: n.uid.to_string(),
|
||||
@@ -660,6 +661,12 @@ impl Core {
|
||||
/// the shared uids; the per-node detail is then gathered best-effort — a single
|
||||
/// node racing with an unshare drops from the list rather than failing the whole
|
||||
/// request.
|
||||
///
|
||||
/// A share with no member, no invitation and no link is left out: it is
|
||||
/// shared with no one. Stop Sharing leaves exactly that behind, since the SDK
|
||||
/// cannot delete a share, and Drive keeps listing it until its backend gets
|
||||
/// round to removing it, minutes later. A share whose details could not all
|
||||
/// be read stays listed, so a failed lookup never hides a live share.
|
||||
pub(crate) fn list_shared_by_me(&self) -> CoreResult<Vec<SharedItem>> {
|
||||
let uids = self
|
||||
.rt
|
||||
@@ -678,24 +685,26 @@ impl Core {
|
||||
let members = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_members(&uid))
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
.map(|m| m.len());
|
||||
let proton_invites = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(&uid))
|
||||
.map(|i| i.len())
|
||||
.unwrap_or(0);
|
||||
.map(|i| i.len());
|
||||
let external_invites = self
|
||||
.rt
|
||||
.block_on(self.client.list_external_invitations(&uid))
|
||||
.map(|i| i.len())
|
||||
.unwrap_or(0);
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(self.client.get_public_link(&uid))
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(public_link_info);
|
||||
.map(|i| i.len());
|
||||
let link = self.rt.block_on(self.client.get_public_link(&uid));
|
||||
if let (Ok(0), Ok(0), Ok(0), Ok(None)) =
|
||||
(&members, &proton_invites, &external_invites, &link)
|
||||
{
|
||||
debug!(%uid, "shared by me: skipping an emptied share");
|
||||
continue;
|
||||
}
|
||||
let members = members.unwrap_or(0);
|
||||
let proton_invites = proton_invites.unwrap_or(0);
|
||||
let external_invites = external_invites.unwrap_or(0);
|
||||
let link = link.ok().flatten().map(public_link_info);
|
||||
items.push(SharedItem {
|
||||
uid: uid.to_string(),
|
||||
is_dir: n.is_folder(),
|
||||
|
||||
@@ -46,6 +46,7 @@ impl Daemon {
|
||||
/// cache and the database the last run left.
|
||||
pub(crate) fn start(dir: &Path, client: FakeClient) -> io::Result<Self> {
|
||||
log_to_test_output();
|
||||
isolate_from_the_developer();
|
||||
let mountpoint = dir.join("mnt");
|
||||
let socket = dir.join("control.sock");
|
||||
std::fs::create_dir_all(&mountpoint)?;
|
||||
@@ -138,6 +139,40 @@ impl Daemon {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the new file `path` under the mount, and let the drain send its
|
||||
/// create only once the create holds the bytes. Otherwise a drain worker
|
||||
/// still busy with the op before can claim the create before the release
|
||||
/// attaches them, which the kernel sends after `close` returns. The file
|
||||
/// then goes out empty, and the bytes follow as a revision, past a hook on
|
||||
/// the next create.
|
||||
pub(crate) fn write_new(&self, path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
let rel = path
|
||||
.strip_prefix(&self.mountpoint)
|
||||
.map_err(io::Error::other)?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let paused = |paused| Request::SetSyncPaused {
|
||||
paused,
|
||||
until: None,
|
||||
};
|
||||
self.request(&paused(true))?;
|
||||
std::fs::write(path, bytes)?;
|
||||
let attached = wait_until(Duration::from_secs(30), || {
|
||||
self.pending().is_ok_and(|items| {
|
||||
items
|
||||
.iter()
|
||||
.any(|item| item.kind == "create" && item.path == rel && item.exportable)
|
||||
})
|
||||
});
|
||||
self.request(&paused(false))?;
|
||||
match attached {
|
||||
true => Ok(()),
|
||||
false => Err(io::Error::other(format!(
|
||||
"{rel}: create never held the bytes"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the daemon and start it again on the same state and link.
|
||||
pub(crate) fn restart(mut self) -> io::Result<Self> {
|
||||
self.shut_down()?;
|
||||
@@ -224,6 +259,33 @@ impl tracing::field::Visit for Message {
|
||||
}
|
||||
}
|
||||
|
||||
/// Point the XDG directories of the whole test process at a scratch
|
||||
/// directory, once, before the first daemon starts.
|
||||
///
|
||||
/// The daemon reads `config.json` through `AppDirs` in a few places (the
|
||||
/// adopted device, the ignore rules, the profile), and those would otherwise
|
||||
/// be the developer's own: an adopted device of their real account made every
|
||||
/// sync folder fail to register against the fake Drive.
|
||||
fn isolate_from_the_developer() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| {
|
||||
let home = scratch("xdg");
|
||||
for (var, sub) in [
|
||||
("XDG_CONFIG_HOME", "config"),
|
||||
("XDG_STATE_HOME", "state"),
|
||||
("XDG_CACHE_HOME", "cache"),
|
||||
("XDG_DATA_HOME", "data"),
|
||||
] {
|
||||
let path = home.join(sub);
|
||||
std::fs::create_dir_all(&path).expect("create a simulation XDG directory");
|
||||
// SAFETY: the simulations run one at a time (`--test-threads=1`,
|
||||
// docs/TESTING.md), and this runs before the first of them starts a
|
||||
// daemon, so no other thread reads the environment meanwhile.
|
||||
unsafe { std::env::set_var(var, &path) };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A client for what `DriveApi` does not cover (sharing, Photos, devices),
|
||||
/// pointed at a closed local port so none of it leaves the machine.
|
||||
fn offline_client() -> ProtonDriveClient {
|
||||
@@ -451,6 +513,36 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||
fn a_partial_write_over_bytes_never_read_takes_another_write_at_once() {
|
||||
// Nothing here ever read the file, so the release of a partial write
|
||||
// had no cached base to fill its gaps from and queued an incomplete
|
||||
// revision. The next write open on it failed with EIO (B204).
|
||||
let drive = FakeDrive::new();
|
||||
drive.device().write("f.txt", b"0123456789");
|
||||
let dir = scratch("partial-write-unread-base");
|
||||
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||
let path = daemon.mountpoint.join("f.txt");
|
||||
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
|
||||
|
||||
write_at(&path, 2, b"ab").unwrap();
|
||||
write_at(&path, 6, b"cd").unwrap();
|
||||
assert!(
|
||||
wait_until(Duration::from_secs(30), || {
|
||||
daemon.pending().is_ok_and(|items| items.is_empty())
|
||||
&& drive.tree().get("f.txt").cloned() == file(b"01ab45cd89")
|
||||
}),
|
||||
"{:?} {:?}",
|
||||
daemon.pending(),
|
||||
drive.tree()
|
||||
);
|
||||
assert_eq!(drive.tree().len(), 1, "{:?}", drive.tree());
|
||||
|
||||
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_file_deleted_offline_while_open_still_reads_and_takes_writes() {
|
||||
@@ -729,7 +821,7 @@ mod tests {
|
||||
let path = daemon.mountpoint.join("c.bin");
|
||||
|
||||
let create = daemon.client.hold_reply_to_next_create();
|
||||
std::fs::write(&path, b"abc").unwrap();
|
||||
daemon.write_new(&path, b"abc").unwrap();
|
||||
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
||||
let uid = drive.lookup("c.bin").unwrap();
|
||||
let landing = daemon.client.hold_next_landing(&uid);
|
||||
@@ -775,7 +867,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let create = daemon.client.hold_reply_to_next_create();
|
||||
std::fs::write(mnt.join("a.txt"), b"old").unwrap();
|
||||
daemon.write_new(&mnt.join("a.txt"), b"old").unwrap();
|
||||
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
||||
let uid = drive.lookup("a.txt").unwrap();
|
||||
let landing = daemon.client.hold_next_landing(&uid);
|
||||
@@ -813,7 +905,7 @@ mod tests {
|
||||
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||
let mnt = daemon.mountpoint.clone();
|
||||
let create = daemon.client.hold_reply_to_next_create();
|
||||
std::fs::write(mnt.join("a.txt"), b"old").unwrap();
|
||||
daemon.write_new(&mnt.join("a.txt"), b"old").unwrap();
|
||||
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
||||
std::fs::write(mnt.join("a.txt"), b"newer").unwrap();
|
||||
let uid = drive.lookup("a.txt").unwrap();
|
||||
@@ -996,7 +1088,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let create = daemon.client.hold_reply_to_next_create();
|
||||
std::fs::write(mnt.join("x/c.bin"), b"abc").unwrap();
|
||||
daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap();
|
||||
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
||||
std::fs::rename(mnt.join("x/c.bin"), mnt.join("y/d")).unwrap();
|
||||
let uid = drive.lookup("x/c.bin").unwrap();
|
||||
@@ -1074,7 +1166,7 @@ mod tests {
|
||||
take_logged();
|
||||
|
||||
let create = daemon.client.hold_reply_to_next_create();
|
||||
std::fs::write(mnt.join("x/c.bin"), b"abc").unwrap();
|
||||
daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap();
|
||||
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
||||
let uid = drive.lookup("x/c.bin").unwrap();
|
||||
// The read-back after the create, the one adopting it, and the size
|
||||
@@ -1122,7 +1214,7 @@ mod tests {
|
||||
}));
|
||||
|
||||
let create = daemon.client.hold_reply_to_next_create();
|
||||
std::fs::write(mnt.join("x/c.bin"), b"abc").unwrap();
|
||||
daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap();
|
||||
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
||||
let uid = drive.lookup("x/c.bin").unwrap();
|
||||
let read_back = daemon.client.hold_next_read(&uid);
|
||||
@@ -1165,7 +1257,7 @@ mod tests {
|
||||
}));
|
||||
|
||||
let create = daemon.client.hold_reply_to_next_create();
|
||||
std::fs::write(mnt.join("x/c.bin"), b"abc").unwrap();
|
||||
daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap();
|
||||
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
||||
// Long enough for the next event poll to bring the file's event, which
|
||||
// drops the folder's listing.
|
||||
@@ -1290,7 +1382,9 @@ mod tests {
|
||||
let folder = drive.lookup("x").unwrap();
|
||||
|
||||
let create = daemon.client.hold_next_create();
|
||||
std::fs::write(mnt.join("x/removed-while-uploading"), b"abc").unwrap();
|
||||
daemon
|
||||
.write_new(&mnt.join("x/removed-while-uploading"), b"abc")
|
||||
.unwrap();
|
||||
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
||||
std::fs::remove_file(mnt.join("x/removed-while-uploading")).unwrap();
|
||||
std::fs::remove_dir(mnt.join("x")).unwrap();
|
||||
@@ -1535,7 +1629,9 @@ mod tests {
|
||||
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
|
||||
|
||||
daemon.client.lose_reply_to_next_create();
|
||||
std::fs::write(daemon.mountpoint.join("f.txt"), b"once").unwrap();
|
||||
daemon
|
||||
.write_new(&daemon.mountpoint.join("f.txt"), b"once")
|
||||
.unwrap();
|
||||
assert!(
|
||||
wait_until(Duration::from_secs(30), || {
|
||||
daemon.pending().is_ok_and(|items| items.is_empty())
|
||||
@@ -1565,7 +1661,7 @@ mod tests {
|
||||
|
||||
let held = daemon.client.hold_reply_to_next_create();
|
||||
daemon.client.lose_reply_to_next_create();
|
||||
std::fs::write(&path, b"first").unwrap();
|
||||
daemon.write_new(&path, b"first").unwrap();
|
||||
assert!(wait_until(Duration::from_secs(30), || held.reached()));
|
||||
std::fs::write(&path, b"second").unwrap();
|
||||
drop(held);
|
||||
@@ -1597,7 +1693,7 @@ mod tests {
|
||||
let path = daemon.mountpoint.join("f.txt");
|
||||
|
||||
daemon.client.lose_reply_to_next_create();
|
||||
std::fs::write(&path, b"gone").unwrap();
|
||||
daemon.write_new(&path, b"gone").unwrap();
|
||||
assert!(wait_until(Duration::from_secs(30), || {
|
||||
drive.tree().contains_key("f.txt")
|
||||
}));
|
||||
@@ -1630,7 +1726,7 @@ mod tests {
|
||||
|
||||
let held = daemon.client.hold_reply_to_next_create();
|
||||
daemon.client.lose_reply_to_next_create();
|
||||
std::fs::write(&path, b"first").unwrap();
|
||||
daemon.write_new(&path, b"first").unwrap();
|
||||
assert!(wait_until(Duration::from_secs(30), || held.reached()));
|
||||
std::fs::write(&path, b"second").unwrap();
|
||||
drop(held);
|
||||
@@ -1670,7 +1766,9 @@ mod tests {
|
||||
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
|
||||
|
||||
daemon.client.lose_reply_to_next_create();
|
||||
std::fs::write(daemon.mountpoint.join("f.txt"), b"first").unwrap();
|
||||
daemon
|
||||
.write_new(&daemon.mountpoint.join("f.txt"), b"first")
|
||||
.unwrap();
|
||||
assert!(wait_until(Duration::from_secs(30), || {
|
||||
daemon
|
||||
.pending()
|
||||
@@ -1709,7 +1807,9 @@ mod tests {
|
||||
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||
|
||||
daemon.client.lose_reply_to_next_create();
|
||||
std::fs::write(daemon.mountpoint.join("f.txt"), b"first").unwrap();
|
||||
daemon
|
||||
.write_new(&daemon.mountpoint.join("f.txt"), b"first")
|
||||
.unwrap();
|
||||
assert!(wait_until(Duration::from_secs(30), || {
|
||||
daemon
|
||||
.pending()
|
||||
@@ -1803,7 +1903,7 @@ mod tests {
|
||||
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
|
||||
|
||||
let held = daemon.client.hold_reply_to_next_create();
|
||||
std::fs::write(mount.join("c.bin"), b"first").unwrap();
|
||||
daemon.write_new(&mount.join("c.bin"), b"first").unwrap();
|
||||
assert!(wait_until(Duration::from_secs(30), || held.reached()));
|
||||
std::fs::rename(mount.join("c.bin"), mount.join("d")).unwrap();
|
||||
std::fs::write(mount.join("c.bin"), b"second").unwrap();
|
||||
@@ -2069,4 +2169,245 @@ mod tests {
|
||||
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_pinned_folder_keeps_every_file_under_it_until_it_is_unpinned() {
|
||||
let drive = FakeDrive::new();
|
||||
let dir = scratch("pin-folder");
|
||||
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||
std::fs::create_dir_all(daemon.mountpoint.join("Docs/sub")).unwrap();
|
||||
std::fs::write(daemon.mountpoint.join("Docs/a.txt"), b"alpha").unwrap();
|
||||
std::fs::write(daemon.mountpoint.join("Docs/sub/b.txt"), b"beta").unwrap();
|
||||
assert!(wait_until(Duration::from_secs(30), || {
|
||||
daemon.pending().is_ok_and(|items| items.is_empty())
|
||||
&& drive.lookup("Docs/sub/b.txt").is_some()
|
||||
}));
|
||||
daemon.request(&Request::PurgeCache).unwrap();
|
||||
let entry = |folder: &str, name: &str| match daemon.request(&Request::ListDir {
|
||||
path: folder.into(),
|
||||
}) {
|
||||
Ok(Response::Entries { entries }) => entries.into_iter().find(|e| e.name == name),
|
||||
_ => None,
|
||||
};
|
||||
assert!(entry("Docs", "a.txt").is_some_and(|e| !e.cached && !e.pinned));
|
||||
|
||||
daemon
|
||||
.request(&Request::Pin {
|
||||
path: "Docs".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
wait_until(Duration::from_secs(30), || {
|
||||
entry("Docs", "a.txt").is_some_and(|e| e.cached)
|
||||
&& entry("Docs/sub", "b.txt").is_some_and(|e| e.cached)
|
||||
}),
|
||||
"the folder's files were downloaded in the background"
|
||||
);
|
||||
let a = entry("Docs", "a.txt").unwrap();
|
||||
assert!(a.pinned && a.pinned_by_folder);
|
||||
assert!(entry("", "Docs").is_some_and(|e| e.pinned && !e.pinned_by_folder));
|
||||
assert!(
|
||||
daemon
|
||||
.request(&Request::Unpin {
|
||||
path: "Docs/a.txt".into(),
|
||||
})
|
||||
.is_err(),
|
||||
"a file kept through its folder cannot be unpinned on its own"
|
||||
);
|
||||
|
||||
daemon
|
||||
.request(&Request::Unpin {
|
||||
path: "Docs".into(),
|
||||
})
|
||||
.unwrap();
|
||||
let a = entry("Docs", "a.txt").unwrap();
|
||||
assert!(!a.pinned && !a.pinned_by_folder && !a.cached);
|
||||
|
||||
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_pinned_while_a_create_in_it_lands_keeps_that_file() {
|
||||
// The pin's download of a file started before the file's landing set
|
||||
// its modification time from Drive, and ended after it: the blob was
|
||||
// stored under the old time, and the file never read as cached (B208).
|
||||
let drive = FakeDrive::new();
|
||||
let dir = scratch("pin-folder-landing");
|
||||
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||
std::fs::create_dir(daemon.mountpoint.join("Docs")).unwrap();
|
||||
assert!(wait_until(Duration::from_secs(30), || {
|
||||
daemon.pending().is_ok_and(|items| items.is_empty())
|
||||
}));
|
||||
let entry = || match daemon.request(&Request::ListDir {
|
||||
path: "Docs".into(),
|
||||
}) {
|
||||
Ok(Response::Entries { entries }) => entries.into_iter().find(|e| e.name == "a.txt"),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Drive stamps the file with the time it landed, a second after it
|
||||
// was written, so the landing moves its modification time.
|
||||
let paused = |paused| Request::SetSyncPaused {
|
||||
paused,
|
||||
until: None,
|
||||
};
|
||||
daemon.request(&paused(true)).unwrap();
|
||||
std::fs::write(daemon.mountpoint.join("Docs/a.txt"), b"alpha").unwrap();
|
||||
std::thread::sleep(Duration::from_millis(1100));
|
||||
let landing = daemon.client.hold_next_landing_of_any();
|
||||
daemon.request(&paused(false)).unwrap();
|
||||
assert!(wait_until(Duration::from_secs(30), || landing.reached()));
|
||||
let real = drive.lookup("Docs/a.txt").unwrap();
|
||||
let read_back = daemon.client.hold_next_read(&real);
|
||||
drop(landing);
|
||||
assert!(wait_until(Duration::from_secs(30), || read_back.reached()));
|
||||
let written = entry().unwrap().modified;
|
||||
|
||||
// Without the blob the landing kept, the pin downloads the file.
|
||||
daemon.request(&Request::PurgeCache).unwrap();
|
||||
|
||||
let download = daemon.client.hold_next_download(&real);
|
||||
take_logged();
|
||||
daemon
|
||||
.request(&Request::Pin {
|
||||
path: "Docs".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(wait_until(Duration::from_secs(30), || download.reached()));
|
||||
drop(read_back);
|
||||
assert!(wait_until(Duration::from_secs(30), || {
|
||||
entry().is_some_and(|e| e.modified != written)
|
||||
}));
|
||||
drop(download);
|
||||
let mut logged = Vec::new();
|
||||
assert!(
|
||||
wait_until(Duration::from_secs(30), || {
|
||||
logged.extend(take_logged());
|
||||
logged
|
||||
.iter()
|
||||
.any(|line| line.starts_with("pin folder: done"))
|
||||
}),
|
||||
"{logged:?}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
entry().is_some_and(|e| e.cached),
|
||||
"the file kept through its folder is available offline: {:?}",
|
||||
entry()
|
||||
);
|
||||
|
||||
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Register `local` as a synced folder in `mode` and wait until the daemon
|
||||
/// lists it that way, returning its id.
|
||||
fn sync_folder(daemon: &Daemon, local: &Path, mode: &str) -> i64 {
|
||||
std::fs::create_dir_all(local).unwrap();
|
||||
let path = local.canonicalize().unwrap().to_string_lossy().into_owned();
|
||||
daemon
|
||||
.request(&Request::AddSyncFolder {
|
||||
local_path: path.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
let listed = |mode: &str| match daemon.request(&Request::ListSyncFolders) {
|
||||
Ok(Response::SyncFolders { items }) => items
|
||||
.into_iter()
|
||||
.find(|f| {
|
||||
f.local_path == path
|
||||
&& f.mode == mode
|
||||
&& f.pending_mode.is_none()
|
||||
&& f.last_sync > 0
|
||||
})
|
||||
.map(|f| f.id),
|
||||
_ => None,
|
||||
};
|
||||
assert!(
|
||||
wait_until(Duration::from_secs(30), || listed("mirror").is_some()),
|
||||
"{} was registered: {:?}",
|
||||
local.display(),
|
||||
daemon.request(&Request::ListSyncFolders)
|
||||
);
|
||||
let id = listed("mirror").unwrap();
|
||||
// Reads lag behind creates, so the remote folder the first pass made
|
||||
// is not readable at once; switching modes reads it.
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
if mode != "mirror" {
|
||||
daemon
|
||||
.request(&Request::SetSyncFolderMode {
|
||||
id,
|
||||
mode: mode.into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
wait_until(Duration::from_secs(30), || listed(mode).is_some()
|
||||
&& is_mounted(local)),
|
||||
"{} switched to {mode}: {:?}",
|
||||
local.display(),
|
||||
daemon.request(&Request::ListSyncFolders)
|
||||
);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||
fn a_move_out_of_an_on_demand_folder_refuses_a_file_still_open_for_writing() {
|
||||
// The account run's "move refuses a file still being written" case let
|
||||
// a move from an on-demand synced folder into a mirror folder go ahead
|
||||
// while the file was open, so the write landed after the move.
|
||||
let drive = FakeDrive::new();
|
||||
let dir = scratch("move-open-out-of-ondemand");
|
||||
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||
let ondemand = dir.join("ondemand");
|
||||
let mirror = dir.join("mirror");
|
||||
sync_folder(&daemon, &ondemand, "ondemand");
|
||||
sync_folder(&daemon, &mirror, "mirror");
|
||||
|
||||
let source = ondemand.join("open");
|
||||
std::fs::create_dir(&source).unwrap();
|
||||
daemon.request(&Request::SyncNow { id: None }).unwrap();
|
||||
assert!(wait_until(Duration::from_secs(30), || {
|
||||
daemon.pending().is_ok_and(|items| items.is_empty())
|
||||
}));
|
||||
// The account run opened the file a moment after its create landed:
|
||||
// the queue was already empty, and the tree still had the
|
||||
// placeholder uid it gave the file.
|
||||
let landing = daemon.client.hold_next_landing_of_any();
|
||||
{
|
||||
let mut file = std::fs::File::create(source.join("open.txt")).unwrap();
|
||||
std::io::Write::write_all(&mut file, b"before").unwrap();
|
||||
file.sync_all().unwrap();
|
||||
}
|
||||
assert!(wait_until(Duration::from_secs(30), || landing.reached()));
|
||||
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(source.join("open.txt"))
|
||||
.unwrap();
|
||||
std::io::Write::write_all(&mut file, b" and after").unwrap();
|
||||
let moved = daemon.request(&Request::Move {
|
||||
path: source.to_string_lossy().into_owned(),
|
||||
new_parent: mirror
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
});
|
||||
drop(file);
|
||||
drop(landing);
|
||||
match moved {
|
||||
Err(e) => assert!(
|
||||
e.to_string().contains("not uploaded yet"),
|
||||
"refused for another reason: {e}"
|
||||
),
|
||||
Ok(answer) => panic!("the move went ahead: {answer:?}"),
|
||||
}
|
||||
|
||||
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@
|
||||
//! - **A full account** ([`FakeDrive::set_quota`]) refusing uploads with
|
||||
//! `InsufficientQuota`.
|
||||
//!
|
||||
//! It also holds the account's computers, each with a root folder outside My
|
||||
//! files, so a simulated daemon can register its own and sync a local folder.
|
||||
//!
|
||||
//! A test can also hold the next read of one node on its way to Drive
|
||||
//! ([`FakeClient::hold_next_read`]), to make a change while the daemon waits
|
||||
//! for the answer, or on its way back ([`FakeClient::hold_answer_to_next_read`]),
|
||||
@@ -54,9 +57,12 @@ use parking_lot::Mutex;
|
||||
use proton_drive_rs::proton_sdk::account::Quota;
|
||||
use proton_drive_rs::proton_sdk::api::ResponseCode;
|
||||
use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError, Result};
|
||||
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId};
|
||||
use proton_drive_rs::proton_sdk::ids::{
|
||||
DeviceUid, DriveEventId, LinkId, NodeUid, ShareId, VolumeId,
|
||||
};
|
||||
use proton_drive_rs::{
|
||||
DriveEvent, DriveEventScopeId, Node, NodeKind, NodeMoveItem, RevisionState, Thumbnail,
|
||||
Device as Computer, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeKind, NodeMoveItem,
|
||||
RevisionState, Thumbnail,
|
||||
};
|
||||
use sha1::{Digest as _, Sha1};
|
||||
|
||||
@@ -111,6 +117,9 @@ struct Server {
|
||||
block_pattern: Vec<u64>,
|
||||
/// The account's storage limit in bytes.
|
||||
max_space: i64,
|
||||
/// The account's computers, oldest first, as `(name, type, root folder)`.
|
||||
/// [`Computer`] is not `Clone`, so each answer builds them afresh.
|
||||
devices: Vec<(String, DeviceType, NodeUid)>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
@@ -162,6 +171,20 @@ impl Server {
|
||||
false
|
||||
}
|
||||
|
||||
/// The `n`th computer of the account, as Drive lists it.
|
||||
fn computer(&self, n: usize) -> Computer {
|
||||
let (name, device_type, root) = &self.devices[n];
|
||||
Computer {
|
||||
uid: DeviceUid::new(format!("device{}", n + 1)),
|
||||
device_type: *device_type,
|
||||
name: Ok(name.clone()),
|
||||
root_folder_uid: root.clone(),
|
||||
creation_time: self.current(root).map_or(0, |remote| remote.created),
|
||||
last_sync_time: None,
|
||||
share_id: ShareId::new(format!("device-share{}", n + 1)),
|
||||
}
|
||||
}
|
||||
|
||||
fn mint_uid(&mut self) -> NodeUid {
|
||||
self.next_link += 1;
|
||||
uid(&format!("L{}", self.next_link))
|
||||
@@ -305,6 +328,7 @@ impl FakeDrive {
|
||||
events: Vec::new(),
|
||||
block_pattern: vec![pdfs_core::cache::BLOCK_SIZE],
|
||||
max_space: 1 << 40,
|
||||
devices: Vec::new(),
|
||||
};
|
||||
let now = now_secs();
|
||||
server.nodes.insert(
|
||||
@@ -338,6 +362,8 @@ impl FakeDrive {
|
||||
hold: Mutex::new(None),
|
||||
hold_answer: Mutex::new(None),
|
||||
hold_trash: Mutex::new(None),
|
||||
hold_download: Mutex::new(None),
|
||||
hold_any_landing: AtomicBool::new(false),
|
||||
hold_landing: Mutex::new(None),
|
||||
drop_after_create: AtomicBool::new(false),
|
||||
lose_create_reply: AtomicBool::new(false),
|
||||
@@ -616,8 +642,13 @@ struct ClientInner {
|
||||
hold_answer: Mutex<Option<Arc<HoldState>>>,
|
||||
/// The trash [`FakeClient::hold_next_trash`] is waiting for.
|
||||
hold_trash: Mutex<Option<Arc<HoldState>>>,
|
||||
/// The download [`FakeClient::hold_next_download`] is waiting for.
|
||||
hold_download: Mutex<Option<Arc<HoldState>>>,
|
||||
/// The landing [`FakeClient::hold_next_landing`] is waiting for.
|
||||
hold_landing: Mutex<Option<Arc<HoldState>>>,
|
||||
/// Set by [`FakeClient::hold_next_landing_of_any`]: hold whichever node
|
||||
/// lands next.
|
||||
hold_any_landing: AtomicBool,
|
||||
/// Set by [`FakeClient::drop_link_after_next_create`].
|
||||
drop_after_create: AtomicBool,
|
||||
/// Set by [`FakeClient::lose_reply_to_next_create`].
|
||||
@@ -755,6 +786,18 @@ impl FakeClient {
|
||||
Held(state)
|
||||
}
|
||||
|
||||
/// Hold the next download of `uid`'s content once Drive has answered it,
|
||||
/// before the bytes reach the daemon, until the answer is dropped.
|
||||
pub(crate) fn hold_next_download(&self, uid: &NodeUid) -> Held {
|
||||
let state = Arc::new(HoldState {
|
||||
uid: uid.clone(),
|
||||
reached: AtomicBool::new(false),
|
||||
released: AtomicBool::new(false),
|
||||
});
|
||||
*self.0.hold_download.lock() = Some(state.clone());
|
||||
Held(state)
|
||||
}
|
||||
|
||||
/// Hold the drain once the next upload to `uid` has landed and its blob
|
||||
/// is let go of, before the tree has the node (`DriveApi::landing`), until
|
||||
/// the answer is dropped.
|
||||
@@ -768,6 +811,14 @@ impl FakeClient {
|
||||
Held(state)
|
||||
}
|
||||
|
||||
/// [`FakeClient::hold_next_landing`] for whichever node lands next, such
|
||||
/// as a new file, whose uid Drive has not minted yet.
|
||||
pub(crate) fn hold_next_landing_of_any(&self) -> Held {
|
||||
let held = self.hold_next_landing(&uid("*"));
|
||||
self.0.hold_any_landing.store(true, Ordering::SeqCst);
|
||||
held
|
||||
}
|
||||
|
||||
/// Make the next file create on Drive, then hold its answer until the
|
||||
/// answer is dropped, as a slow link does.
|
||||
pub(crate) fn hold_reply_to_next_create(&self) -> Held {
|
||||
@@ -1396,6 +1447,8 @@ impl DriveApi for FakeClient {
|
||||
.ok_or_else(|| api(ResponseCode::DoesNotExist, "file not found"))
|
||||
})
|
||||
.await?;
|
||||
self.wait_if_held(&self.0.hold_download, std::slice::from_ref(uid))
|
||||
.await;
|
||||
output.write_all(&content).map_err(io_error)
|
||||
}
|
||||
|
||||
@@ -1484,8 +1537,12 @@ impl DriveApi for FakeClient {
|
||||
fn landing(&self, uid: &NodeUid) {
|
||||
let state = {
|
||||
let mut hold = self.0.hold_landing.lock();
|
||||
let any = self.0.hold_any_landing.load(Ordering::SeqCst);
|
||||
match hold.as_ref() {
|
||||
Some(state) if state.uid == *uid => hold.take(),
|
||||
Some(state) if any || state.uid == *uid => {
|
||||
self.0.hold_any_landing.store(false, Ordering::SeqCst);
|
||||
hold.take()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
@@ -1506,6 +1563,42 @@ impl DriveApi for FakeClient {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn enumerate_devices(&self) -> Result<Vec<Computer>> {
|
||||
self.request(false, |server| {
|
||||
Ok((0..server.devices.len())
|
||||
.map(|n| server.computer(n))
|
||||
.collect())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn create_device(&self, name: &str, device_type: DeviceType) -> Result<Computer> {
|
||||
self.request(true, |server| {
|
||||
// A device root has no parent: like on Drive, it is not in My files.
|
||||
// It is listed at once, as the My files root is: the listing lag is
|
||||
// about children appearing, and the daemon lists the new root next.
|
||||
let root = server.mint_uid();
|
||||
let now = now_secs();
|
||||
server.nodes.insert(
|
||||
root.clone(),
|
||||
vec![(
|
||||
Instant::now() - HISTORY,
|
||||
Some(Remote {
|
||||
parent: None,
|
||||
name: name.to_owned(),
|
||||
trashed: false,
|
||||
created: now,
|
||||
modified: now,
|
||||
file: None,
|
||||
}),
|
||||
)],
|
||||
);
|
||||
server.devices.push((name.to_owned(), device_type, root));
|
||||
Ok(server.computer(server.devices.len() - 1))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn enumerate_trash_node_uids(&self) -> Result<Vec<NodeUid>> {
|
||||
self.request(false, |server| {
|
||||
Ok(server
|
||||
|
||||
+569
-100
@@ -50,7 +50,7 @@ mod planner;
|
||||
use engine::SYNC_CONCURRENCY;
|
||||
pub(crate) use engine::{SyncMsg, spawn};
|
||||
#[cfg(test)]
|
||||
use engine::{classify, settle_with};
|
||||
use engine::{classify, event_target, settle_with};
|
||||
#[cfg(test)]
|
||||
use local_scan::is_write_mode;
|
||||
use local_scan::open_for_write_set;
|
||||
@@ -59,8 +59,9 @@ pub(crate) use planner::base_name;
|
||||
#[cfg(test)]
|
||||
use planner::conflict_path;
|
||||
use planner::{
|
||||
FilePlan, classification_order, conflict_path_with_suffix, filter_baseline, guard_local_wipe,
|
||||
join_rel, parent_rel, plan_file, rel_to_path, remote_sig, unchanged_remote_size,
|
||||
FilePlan, Unscanned, classification_order, conflict_path_with_suffix, filter_baseline,
|
||||
guard_local_wipe, join_rel, local_deletions, parent_rel, plan_file, rel_to_path, remote_sig,
|
||||
tracked_baseline, unchanged_remote_size,
|
||||
};
|
||||
/// What a [`Pending`] op did, so the reconcile can update shared state on the
|
||||
/// engine thread (never inside a task).
|
||||
@@ -187,6 +188,7 @@ impl Core {
|
||||
"idle"
|
||||
};
|
||||
let _ = self.db.sync_folder_set_state(folder.id, state, now_secs());
|
||||
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
|
||||
// feed would fill with "synced, 0 changes" every two minutes.
|
||||
@@ -243,6 +245,37 @@ impl Core {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the activity feed what a pass over this folder had to leave out
|
||||
/// because it cannot be read, once each time that changes. Every other path
|
||||
/// in the folder still synced, so this does not make the folder's state an
|
||||
/// error; but a folder the user has no permission for is never going to
|
||||
/// reach Drive, and they should hear that once rather than not at all
|
||||
/// (B196).
|
||||
fn report_unscanned(&self, folder_id: i64, name: &str, unscanned: &[String]) {
|
||||
let previous = {
|
||||
let mut last = self.sync_unscanned.lock();
|
||||
if unscanned.is_empty() {
|
||||
last.remove(&folder_id)
|
||||
} else {
|
||||
last.insert(folder_id, unscanned.to_vec())
|
||||
}
|
||||
};
|
||||
if unscanned.is_empty() || previous.as_deref() == Some(unscanned) {
|
||||
return;
|
||||
}
|
||||
const SHOWN: usize = 3;
|
||||
let mut detail = unscanned[..unscanned.len().min(SHOWN)].join(", ");
|
||||
if unscanned.len() > SHOWN {
|
||||
detail.push_str(&format!(" and {} more", unscanned.len() - SHOWN));
|
||||
}
|
||||
self.log_activity(
|
||||
ActivityKind::Sync,
|
||||
format!("{} item(s) in {name} cannot be synced", unscanned.len()),
|
||||
detail,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Compile the ignore rules for a synced folder: the global patterns from
|
||||
/// the config, plus any `.pdfsignore` at `local_root`.
|
||||
///
|
||||
@@ -250,7 +283,7 @@ impl Core {
|
||||
/// than to "ignore nothing" — the defaults exclude build and VCS trees, and
|
||||
/// silently uploading those because a config read failed is the outcome this
|
||||
/// feature exists to prevent.
|
||||
fn ignore_rules(&self, local_root: &Path) -> IgnoreRules {
|
||||
pub(crate) fn ignore_rules(&self, local_root: &Path) -> IgnoreRules {
|
||||
let globals = match pdfs_core::config::AppDirs::new() {
|
||||
Ok(dirs) => dirs.load_config().resolved_ignore_patterns(),
|
||||
Err(e) => {
|
||||
@@ -313,15 +346,23 @@ impl Core {
|
||||
self.progress_scan_total(folder_id, baseline.len());
|
||||
|
||||
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
||||
let mut unscanned = Unscanned::default();
|
||||
let writing = open_for_write_set(local_root);
|
||||
self.walk_local(
|
||||
folder_id, local_root, local_root, rules, &writing, &mut local,
|
||||
folder_id,
|
||||
local_root,
|
||||
rules,
|
||||
&writing,
|
||||
&mut local,
|
||||
&mut unscanned,
|
||||
)?;
|
||||
// The guard compares against the paths this pass could still see: an
|
||||
// ignored path is absent from `local` by rule, not by loss, and counting
|
||||
// those as missing would trip the guard on every pass — wedging the
|
||||
// folder's sync permanently — the moment a rule covers the whole tree.
|
||||
guard_local_wipe(&filter_baseline(&baseline, rules), &local)?;
|
||||
// ignored or unreadable path is absent from `local` by rule or by
|
||||
// failure, not by loss, and counting those as missing would trip the
|
||||
// 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)?;
|
||||
|
||||
// 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
|
||||
@@ -391,6 +432,7 @@ impl Core {
|
||||
|
||||
let mut outcome = Outcome {
|
||||
deferred: local.values().filter(|item| item.open_for_write).count(),
|
||||
unscanned: unscanned.report(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut order: Vec<&String> = local.keys().collect();
|
||||
@@ -515,13 +557,8 @@ 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 mut missing: Vec<&String> = baseline
|
||||
.keys()
|
||||
.filter(|rel| !local.contains_key(*rel))
|
||||
.collect();
|
||||
missing.sort_by_key(|p| p.matches('/').count());
|
||||
let mut trashed: Vec<String> = Vec::new();
|
||||
for rel in missing {
|
||||
for rel in &local_deletions(&tracked, &local) {
|
||||
if trashed
|
||||
.iter()
|
||||
.any(|dir| rel.starts_with(&format!("{dir}/")))
|
||||
@@ -586,12 +623,18 @@ impl Core {
|
||||
self.progress_scan_total(folder_id, baseline.len() * 2);
|
||||
|
||||
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
||||
let mut unscanned = Unscanned::default();
|
||||
let writing = open_for_write_set(local_root);
|
||||
self.walk_local(
|
||||
folder_id, local_root, local_root, rules, &writing, &mut local,
|
||||
folder_id,
|
||||
local_root,
|
||||
rules,
|
||||
&writing,
|
||||
&mut local,
|
||||
&mut unscanned,
|
||||
)?;
|
||||
// See `push_pass`: the guard must not count rule-excluded paths as lost.
|
||||
guard_local_wipe(&filter_baseline(&baseline, rules), &local)?;
|
||||
// See `push_pass`: the guard must not count excluded paths as lost.
|
||||
guard_local_wipe(&tracked_baseline(&baseline, rules, &unscanned), &local)?;
|
||||
|
||||
let mut remote: HashMap<String, RemoteItem> = HashMap::new();
|
||||
let mut remote_dirs: HashMap<String, NodeUid> = HashMap::new();
|
||||
@@ -605,9 +648,15 @@ impl Core {
|
||||
&baseline,
|
||||
)?;
|
||||
|
||||
let order = classification_order(&local, &remote, &baseline, rules);
|
||||
let mut order = classification_order(&local, &remote, &baseline, rules);
|
||||
// A path the scan could not read is no more a local deletion than an
|
||||
// ignored one, and a remote path under it has nowhere to be written.
|
||||
order.retain(|rel| !unscanned.covers(rel));
|
||||
|
||||
let mut outcome = Outcome::default();
|
||||
let mut outcome = Outcome {
|
||||
unscanned: unscanned.report(),
|
||||
..Default::default()
|
||||
};
|
||||
// Folders to delete, collected here and removed deepest-first at the end
|
||||
// so a parent is never removed before its children.
|
||||
let mut delete_local_dirs: Vec<String> = Vec::new();
|
||||
@@ -873,19 +922,19 @@ impl Core {
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Recursively walk a local directory into `out`, keyed by `/`-joined relative
|
||||
/// path. Symlinks and other special files are skipped. Reports each entry to the
|
||||
/// pass's scan progress.
|
||||
/// Recursively walk a local tree into `out`, keyed by `/`-joined relative
|
||||
/// path. Symlinks and other special files are skipped, and paths that cannot
|
||||
/// be read go to `unscanned`. Reports each entry to the pass's scan progress.
|
||||
fn walk_local(
|
||||
&self,
|
||||
folder_id: i64,
|
||||
root: &Path,
|
||||
dir: &Path,
|
||||
rules: &IgnoreRules,
|
||||
writing: &HashSet<PathBuf>,
|
||||
out: &mut HashMap<String, LocalItem>,
|
||||
unscanned: &mut Unscanned,
|
||||
) -> Result<(), String> {
|
||||
walk_local_tree(root, dir, rules, writing, out, &mut |rel| {
|
||||
walk_local_tree(root, rules, writing, out, unscanned, &mut |rel| {
|
||||
self.progress_scanned(folder_id, base_name(rel))
|
||||
})
|
||||
}
|
||||
@@ -1582,51 +1631,120 @@ fn removed_locally(result: std::io::Result<()>) -> std::io::Result<()> {
|
||||
}
|
||||
|
||||
/// The body of [`Core::walk_local`], without a `Core`: recursively collect the
|
||||
/// local tree under `dir` into `out`, reporting each entry to `scanned`.
|
||||
/// local tree under `root` into `out`, reporting each entry to `scanned`.
|
||||
///
|
||||
/// **Every failure here is fatal to the pass, deliberately.** A `read_dir` that
|
||||
/// fails, an entry that cannot be stat-ed, a name that is not UTF-8 — each of
|
||||
/// them would, if skipped, leave `out` describing a *smaller* tree than the one
|
||||
/// on disk. Reconciliation reads a path present in the baseline and absent from
|
||||
/// the scan as "the user deleted this", and propagates that to Drive. So an
|
||||
/// unreadable subdirectory would trash its remote counterpart, and the deeper
|
||||
/// the failure, the more it takes with it (`docs/BUGS.md` B55). Refusing the
|
||||
/// whole pass keeps the previous baseline and leaves the remote alone; the next
|
||||
/// pass retries once the transient condition clears.
|
||||
/// **A path the scan cannot read is left out and recorded in `unscanned`, never
|
||||
/// silently dropped.** Every pass drops what `unscanned` covers from
|
||||
/// classification, so a baseline row under it is neither a local deletion nor a
|
||||
/// download target (`docs/BUGS.md` B55). That covers a subfolder the pass has no
|
||||
/// permission for, an entry that vanished between the listing and its `stat`,
|
||||
/// and a name that is not UTF-8. Failing the whole pass on any of them, as this
|
||||
/// did before, left one root-owned folder stopping everything else in the tree
|
||||
/// from syncing, on every pass, for good (B196).
|
||||
///
|
||||
/// The root is the exception, and so is any other I/O error: an absent or
|
||||
/// unreadable root fails the pass, since leaving the root out would leave
|
||||
/// nothing in, and an error this cannot name a cause for is not one to guess
|
||||
/// about.
|
||||
///
|
||||
/// Split out from the method so exactly that can be tested against a real tree
|
||||
/// with a real unreadable directory in it, which needs no `Core` at all.
|
||||
fn walk_local_tree(
|
||||
root: &Path,
|
||||
rules: &IgnoreRules,
|
||||
writing: &HashSet<PathBuf>,
|
||||
out: &mut HashMap<String, LocalItem>,
|
||||
unscanned: &mut Unscanned,
|
||||
scanned: &mut dyn FnMut(&str),
|
||||
) -> Result<(), String> {
|
||||
walk_local_dir(root, root, rules, writing, out, unscanned, scanned).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Why [`walk_local_dir`] could not finish a folder.
|
||||
enum ScanError {
|
||||
/// An I/O error on `what`, which the caller may leave out of the pass.
|
||||
Io { what: String, error: std::io::Error },
|
||||
/// Anything else, which fails the pass.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ScanError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ScanError::Io { what, error } => write!(f, "{what}: {error}"),
|
||||
ScanError::Failed(message) => f.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave `rel` out of the pass for `failure`, or hand it back when it is one
|
||||
/// that fails the pass. Permission denied is reported to the user; an entry
|
||||
/// that vanished mid-scan is not, since the next pass sees it settled.
|
||||
fn leave_out(rel: &str, failure: ScanError, unscanned: &mut Unscanned) -> Result<(), ScanError> {
|
||||
let ScanError::Io { what, error } = &failure else {
|
||||
return Err(failure);
|
||||
};
|
||||
match error.kind() {
|
||||
std::io::ErrorKind::PermissionDenied => {
|
||||
warn!(%what, %error, "sync: cannot read; leaving it out of the pass");
|
||||
unscanned.unreadable(rel.to_string(), &error.to_string());
|
||||
Ok(())
|
||||
}
|
||||
std::io::ErrorKind::NotFound => {
|
||||
debug!(%what, "sync: gone mid-scan; leaving it out of the pass");
|
||||
unscanned.vanished(rel.to_string());
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(failure),
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_local_dir(
|
||||
root: &Path,
|
||||
dir: &Path,
|
||||
rules: &IgnoreRules,
|
||||
writing: &HashSet<PathBuf>,
|
||||
out: &mut HashMap<String, LocalItem>,
|
||||
unscanned: &mut Unscanned,
|
||||
scanned: &mut dyn FnMut(&str),
|
||||
) -> Result<(), String> {
|
||||
let entries = std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))?;
|
||||
) -> Result<(), ScanError> {
|
||||
let io = |what: String| move |error| ScanError::Io { what, error };
|
||||
let entries = std::fs::read_dir(dir).map_err(io(format!("read {}", dir.display())))?;
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("read entry in {}: {e}", dir.display()))?;
|
||||
let entry = entry.map_err(io(format!("read entry in {}", dir.display())))?;
|
||||
let path = entry.path();
|
||||
let meta = std::fs::symlink_metadata(&path)
|
||||
.map_err(|e| format!("stat {}: {e}", path.display()))?;
|
||||
let stripped = path.strip_prefix(root).map_err(|e| {
|
||||
format!(
|
||||
ScanError::Failed(format!(
|
||||
"local path {} escaped sync root {}: {e}",
|
||||
path.display(),
|
||||
root.display()
|
||||
)
|
||||
})?;
|
||||
let rel = stripped.to_str().ok_or_else(|| {
|
||||
format!(
|
||||
"local path {} is not valid UTF-8; refusing a destructive sync pass",
|
||||
path.display()
|
||||
)
|
||||
))
|
||||
})?;
|
||||
let Some(rel) = stripped.to_str() else {
|
||||
// Nothing can be keyed by this name: not the baseline, which only
|
||||
// ever held paths this scan produced, and not Drive, whose names are
|
||||
// UTF-8. Leaving it out cannot read as a deletion of anything.
|
||||
warn!(path = %path.display(), "sync: name is not valid UTF-8; leaving it out of the pass");
|
||||
unscanned.unreadable(
|
||||
stripped.to_string_lossy().into_owned(),
|
||||
"name is not valid UTF-8",
|
||||
);
|
||||
continue;
|
||||
};
|
||||
// Ignore our own in-flight download temp files.
|
||||
if rel.contains(".pdfs-tmp-") {
|
||||
continue;
|
||||
}
|
||||
let meta = match std::fs::symlink_metadata(&path) {
|
||||
Ok(meta) => meta,
|
||||
// Gone since the listing, or in a folder that lists but does not
|
||||
// allow `stat`. Either way this pass cannot say what it is.
|
||||
Err(error) => {
|
||||
let what = format!("stat {}", path.display());
|
||||
leave_out(rel, ScanError::Io { what, error }, unscanned)?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Ignored paths are dropped here as well as from the classification
|
||||
// union — not for correctness (the union filter is what makes this
|
||||
// safe) but so an ignored `node_modules/` is never descended into or
|
||||
@@ -1646,7 +1764,15 @@ fn walk_local_tree(
|
||||
},
|
||||
);
|
||||
scanned(rel);
|
||||
walk_local_tree(root, &path, rules, writing, out, scanned)?;
|
||||
if let Err(failure) =
|
||||
walk_local_dir(root, &path, rules, writing, out, unscanned, scanned)
|
||||
{
|
||||
leave_out(rel, failure, unscanned)?;
|
||||
// What the folder did list before failing goes with it: a
|
||||
// half-read folder is as unknown as an unread one.
|
||||
let prefix = format!("{rel}/");
|
||||
out.retain(|k, _| k != rel && !k.starts_with(&prefix));
|
||||
}
|
||||
} else if meta.is_file() {
|
||||
// Check whether any process holds this file open for writing.
|
||||
// The canonical path is used because /proc/*/fd links resolve
|
||||
@@ -1684,7 +1810,7 @@ pub(crate) fn mirror_subtree_unsynced(
|
||||
baseline: &HashMap<String, StoredSyncEntry>,
|
||||
) -> Option<String> {
|
||||
let mut seen = HashSet::new();
|
||||
if let Some(reason) = unsynced_local(root, rel, baseline, &mut seen) {
|
||||
if let Some(reason) = unsynced_local(root, rel, baseline, None, &mut seen) {
|
||||
return Some(reason);
|
||||
}
|
||||
let prefix = format!("{rel}/");
|
||||
@@ -1695,45 +1821,93 @@ pub(crate) fn mirror_subtree_unsynced(
|
||||
.map(|r| format!("{r} was deleted here but not on Proton Drive yet"))
|
||||
}
|
||||
|
||||
/// [`mirror_subtree_unsynced`] for a whole mirror folder: why its local copy
|
||||
/// holds something Proton Drive does not, or `None` when evicting it loses
|
||||
/// nothing. The switch to on-demand asks this before it empties the folder.
|
||||
///
|
||||
/// A push pass that ends `idle` is not enough on its own. It never uploads an
|
||||
/// ignored path, a symlink or a folder it cannot read, it defers a file open
|
||||
/// for writing, and a file can change after it finishes; the eviction deleted
|
||||
/// all of those for good (B198).
|
||||
///
|
||||
/// A tracked path deleted here but still in the baseline counts, since the
|
||||
/// mount would show it again. An ignored one does not: ignoring never carries
|
||||
/// a deletion to Drive, so its row stays in the baseline for good, and holding
|
||||
/// the switch on it would hold it forever.
|
||||
pub(crate) fn mirror_tree_unsynced(
|
||||
root: &Path,
|
||||
baseline: &HashMap<String, StoredSyncEntry>,
|
||||
rules: &IgnoreRules,
|
||||
) -> Option<String> {
|
||||
let mut seen = HashSet::new();
|
||||
if let Some(reason) = unsynced_local(root, "", baseline, Some(rules), &mut seen) {
|
||||
return Some(reason);
|
||||
}
|
||||
let mut missing: Vec<String> = filter_baseline(baseline, rules)
|
||||
.into_keys()
|
||||
.filter(|r| !seen.contains(r.as_str()))
|
||||
.collect();
|
||||
missing.sort_unstable();
|
||||
missing
|
||||
.first()
|
||||
.map(|r| format!("{r} was deleted here but not on Proton Drive yet"))
|
||||
}
|
||||
|
||||
/// 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(
|
||||
root: &Path,
|
||||
rel: &str,
|
||||
baseline: &HashMap<String, StoredSyncEntry>,
|
||||
rules: Option<&IgnoreRules>,
|
||||
seen: &mut HashSet<String>,
|
||||
) -> Option<String> {
|
||||
let path = root.join(rel_to_path(rel));
|
||||
let meta = match std::fs::symlink_metadata(&path) {
|
||||
Ok(meta) => meta,
|
||||
Err(e) => return Some(format!("cannot read {rel}: {e}")),
|
||||
let path = if rel.is_empty() {
|
||||
root.to_path_buf()
|
||||
} else {
|
||||
root.join(rel_to_path(rel))
|
||||
};
|
||||
let Some(base) = baseline.get(rel) else {
|
||||
return Some(format!("{rel} is not on Proton Drive yet"));
|
||||
};
|
||||
seen.insert(rel.to_string());
|
||||
if meta.is_file() {
|
||||
return (!LocalSig::from(&meta).same_content(&LocalSig::from(base)))
|
||||
.then(|| format!("{rel} has changes that are not on Proton Drive yet"));
|
||||
}
|
||||
if !meta.is_dir() {
|
||||
return Some(format!("{rel} is not a regular file or folder"));
|
||||
// The folder itself has no baseline row; only what is in it does.
|
||||
if !rel.is_empty() {
|
||||
let meta = match std::fs::symlink_metadata(&path) {
|
||||
Ok(meta) => meta,
|
||||
Err(e) => return Some(format!("cannot read {rel}: {e}")),
|
||||
};
|
||||
let Some(base) = baseline.get(rel) else {
|
||||
if rules.is_some_and(|rules| rules.is_ignored(rel, meta.is_dir())) {
|
||||
return Some(format!(
|
||||
"{rel} is excluded from sync, so it is only on this computer"
|
||||
));
|
||||
}
|
||||
return Some(format!("{rel} is not on Proton Drive yet"));
|
||||
};
|
||||
seen.insert(rel.to_string());
|
||||
if meta.is_file() {
|
||||
return (!LocalSig::from(&meta).same_content(&LocalSig::from(base)))
|
||||
.then(|| format!("{rel} has changes that are not on Proton Drive yet"));
|
||||
}
|
||||
if !meta.is_dir() {
|
||||
return Some(format!("{rel} is not a regular file or folder"));
|
||||
}
|
||||
}
|
||||
let shown = if rel.is_empty() { "the folder" } else { rel };
|
||||
let entries = match std::fs::read_dir(&path) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => return Some(format!("cannot read {rel}: {e}")),
|
||||
Err(e) => return Some(format!("cannot read {shown}: {e}")),
|
||||
};
|
||||
for entry in entries {
|
||||
let name = match entry {
|
||||
Ok(entry) => entry.file_name(),
|
||||
Err(e) => return Some(format!("cannot read {rel}: {e}")),
|
||||
Err(e) => return Some(format!("cannot read {shown}: {e}")),
|
||||
};
|
||||
let Some(name) = name.to_str() else {
|
||||
return Some(format!("{rel} holds a name that is not valid UTF-8"));
|
||||
return Some(format!("{shown} holds a name that is not valid UTF-8"));
|
||||
};
|
||||
// A download the engine left half done; it is the engine's to clean up.
|
||||
if name.contains(".pdfs-tmp-") {
|
||||
continue;
|
||||
}
|
||||
if let Some(reason) = unsynced_local(root, &format!("{rel}/{name}"), baseline, seen) {
|
||||
if let Some(reason) = unsynced_local(root, &join_rel(rel, name), baseline, rules, seen) {
|
||||
return Some(reason);
|
||||
}
|
||||
}
|
||||
@@ -1923,6 +2097,32 @@ mod tests {
|
||||
(ids, started.elapsed())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_in_one_mirror_folder_reconciles_only_that_folder() {
|
||||
let watched = vec![
|
||||
(PathBuf::from("/home/me/Docs"), 1),
|
||||
(PathBuf::from("/home/me/Photos"), 2),
|
||||
(PathBuf::from("/home/me/Photos/Raw"), 3),
|
||||
];
|
||||
let target = |paths: &[&str]| {
|
||||
let paths: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
|
||||
match event_target(&watched, &paths) {
|
||||
SyncMsg::Reconcile(id) => Some(id),
|
||||
SyncMsg::ReconcileAll => None,
|
||||
_ => panic!("unexpected message"),
|
||||
}
|
||||
};
|
||||
assert_eq!(target(&["/home/me/Docs/a.txt"]), Some(1));
|
||||
// A rename within one folder.
|
||||
assert_eq!(target(&["/home/me/Docs/a", "/home/me/Docs/b"]), Some(1));
|
||||
// The deepest root wins.
|
||||
assert_eq!(target(&["/home/me/Photos/Raw/x.cr2"]), Some(3));
|
||||
// Across folders, outside them all, or no path at all: walk everything.
|
||||
assert_eq!(target(&["/home/me/Docs/a", "/home/me/Photos/a"]), None);
|
||||
assert_eq!(target(&["/home/me/Docsx/a"]), None);
|
||||
assert_eq!(target(&[]), None);
|
||||
}
|
||||
|
||||
/// The bug this replaced a fixed sleep for: a save that keeps writing past
|
||||
/// the debounce used to be walked mid-write, uploading a torn snapshot as a
|
||||
/// real revision. The window has to restart on every event, so settling
|
||||
@@ -2154,7 +2354,15 @@ mod tests {
|
||||
let writing = HashSet::new();
|
||||
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
||||
let mut progress = no_progress();
|
||||
walk_local_tree(&root, &root, &rules, &writing, &mut local, &mut progress).unwrap();
|
||||
walk_local_tree(
|
||||
&root,
|
||||
&rules,
|
||||
&writing,
|
||||
&mut local,
|
||||
&mut Unscanned::default(),
|
||||
&mut progress,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(local.is_empty(), "the replacement root is genuinely empty");
|
||||
|
||||
for size in 1..=3 {
|
||||
@@ -2170,7 +2378,15 @@ mod tests {
|
||||
// remounted folder resumes rather than staying wedged.
|
||||
std::fs::write(root.join("file0.txt"), b"back").unwrap();
|
||||
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
||||
walk_local_tree(&root, &root, &rules, &writing, &mut local, &mut progress).unwrap();
|
||||
walk_local_tree(
|
||||
&root,
|
||||
&rules,
|
||||
&writing,
|
||||
&mut local,
|
||||
&mut Unscanned::default(),
|
||||
&mut progress,
|
||||
)
|
||||
.unwrap();
|
||||
let baseline: HashMap<String, ()> = [("file0.txt".to_string(), ())].into_iter().collect();
|
||||
assert!(guard_local_wipe(&filter_baseline(&baseline, &rules), &local).is_ok());
|
||||
|
||||
@@ -2475,33 +2691,176 @@ mod tests {
|
||||
|_: &str| {}
|
||||
}
|
||||
|
||||
/// A directory the pass cannot read must fail the pass. Omitting it would
|
||||
/// hand reconciliation a baseline-minus-subtree, which it reads as the user
|
||||
/// having deleted that subtree and propagates to Drive (B55).
|
||||
/// Scan `root` with no rules, returning what it found and left out.
|
||||
fn scan(root: &Path) -> Result<(HashMap<String, LocalItem>, Unscanned), String> {
|
||||
let mut out = HashMap::new();
|
||||
let mut unscanned = Unscanned::default();
|
||||
let mut progress = no_progress();
|
||||
walk_local_tree(
|
||||
root,
|
||||
&rules_for(&[]),
|
||||
&HashSet::new(),
|
||||
&mut out,
|
||||
&mut unscanned,
|
||||
&mut progress,
|
||||
)?;
|
||||
Ok((out, unscanned))
|
||||
}
|
||||
|
||||
fn sorted_keys<V>(map: &HashMap<String, V>) -> Vec<&str> {
|
||||
let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
|
||||
keys.sort_unstable();
|
||||
keys
|
||||
}
|
||||
|
||||
/// B196, the report in #33: a folder the user may not read, such as a
|
||||
/// container's root-owned cache, is left out of the pass and named, and
|
||||
/// everything around it still scans. It used to fail the whole pass, so
|
||||
/// nothing else in the tree ever synced again.
|
||||
#[test]
|
||||
fn an_unreadable_subdirectory_fails_the_scan_instead_of_shrinking_it() {
|
||||
fn an_unreadable_subdirectory_is_left_out_and_the_rest_scans() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let root = sync_test_dir("scan-eacces");
|
||||
std::fs::write(root.join("keep.txt"), b"a").unwrap();
|
||||
let locked = root.join("locked");
|
||||
std::fs::create_dir_all(root.join("docker/cache")).unwrap();
|
||||
std::fs::write(root.join("docker/compose.yml"), b"c").unwrap();
|
||||
let locked = root.join("docker/cache/locked");
|
||||
std::fs::create_dir(&locked).unwrap();
|
||||
std::fs::write(locked.join("inner.txt"), b"b").unwrap();
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
|
||||
let rules = rules_for(&[]);
|
||||
let writing = HashSet::new();
|
||||
let mut out = HashMap::new();
|
||||
let mut progress = no_progress();
|
||||
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
|
||||
let result = scan(&root);
|
||||
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
|
||||
let error = result.expect_err("an unreadable directory must abort the pass");
|
||||
assert!(
|
||||
error.contains("locked"),
|
||||
"the error should name the subtree it could not read: {error}"
|
||||
let (out, unscanned) = result.expect("one unreadable folder must not fail the pass");
|
||||
assert_eq!(
|
||||
sorted_keys(&out),
|
||||
vec!["docker", "docker/cache", "docker/compose.yml", "keep.txt"]
|
||||
);
|
||||
assert!(unscanned.covers("docker/cache/locked"));
|
||||
assert!(unscanned.covers("docker/cache/locked/inner.txt"));
|
||||
assert!(!unscanned.covers("docker/cache"));
|
||||
let report = unscanned.report();
|
||||
assert_eq!(report.len(), 1);
|
||||
assert!(report[0].starts_with("docker/cache/locked ("), "{report:?}");
|
||||
}
|
||||
|
||||
/// A folder with read but no search permission lists its names but cannot
|
||||
/// `stat` them. Whatever it did list must go with it: a half-read folder
|
||||
/// is as unknown as an unread one.
|
||||
#[test]
|
||||
fn a_listable_but_unsearchable_folder_is_left_out_whole() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let root = sync_test_dir("scan-no-search");
|
||||
std::fs::write(root.join("keep.txt"), b"a").unwrap();
|
||||
let half = root.join("half");
|
||||
std::fs::create_dir(&half).unwrap();
|
||||
std::fs::write(half.join("one.txt"), b"1").unwrap();
|
||||
std::fs::write(half.join("two.txt"), b"2").unwrap();
|
||||
std::fs::set_permissions(&half, std::fs::Permissions::from_mode(0o400)).unwrap();
|
||||
|
||||
let result = scan(&root);
|
||||
|
||||
std::fs::set_permissions(&half, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
|
||||
let (out, unscanned) = result.unwrap();
|
||||
// Each entry fails its own `stat` and is left out by name.
|
||||
assert_eq!(sorted_keys(&out), vec!["half", "keep.txt"]);
|
||||
assert!(unscanned.covers("half/one.txt"));
|
||||
assert!(unscanned.covers("half/two.txt"));
|
||||
}
|
||||
|
||||
/// The other half of B196: what was left out is not a deletion, and not a
|
||||
/// download target either. Its baseline rows leave the wipe guard's set
|
||||
/// and a push pass's deletions, and its paths leave classification.
|
||||
#[test]
|
||||
fn a_path_the_scan_left_out_is_never_a_local_deletion() {
|
||||
let mut baseline = baseline_with("keep.txt", 1, 1);
|
||||
baseline.extend(baseline_with("locked", 0, 0));
|
||||
baseline.extend(baseline_with("locked/inner.txt", 1, 1));
|
||||
baseline.extend(baseline_with("gone.txt", 1, 1));
|
||||
let local: HashMap<String, LocalItem> = [("keep.txt".to_string(), local_item(false))]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let mut unscanned = Unscanned::default();
|
||||
unscanned.unreadable("locked".to_string(), "Permission denied");
|
||||
let rules = rules_for(&[]);
|
||||
|
||||
let tracked = tracked_baseline(&baseline, &rules, &unscanned);
|
||||
assert_eq!(sorted_keys(&tracked), vec!["gone.txt", "keep.txt"]);
|
||||
assert_eq!(local_deletions(&tracked, &local), vec!["gone.txt"]);
|
||||
|
||||
// Every tracked path but one is gone, and the guard still lets the
|
||||
// pass run: one survivor proves the tree is there.
|
||||
assert!(guard_local_wipe(&tracked, &local).is_ok());
|
||||
// With everything readable left out, nothing is tracked, so nothing
|
||||
// can be lost and the guard has nothing to object to.
|
||||
let mut all = Unscanned::default();
|
||||
all.unreadable("keep.txt".to_string(), "Permission denied");
|
||||
all.unreadable("locked".to_string(), "Permission denied");
|
||||
all.vanished("gone.txt".to_string());
|
||||
assert!(tracked_baseline(&baseline, &rules, &all).is_empty());
|
||||
|
||||
let remote: HashMap<String, RemoteItem> = HashMap::new();
|
||||
let mut order = classification_order(&local, &remote, &baseline, &rules);
|
||||
order.retain(|rel| !unscanned.covers(rel));
|
||||
order.sort_unstable();
|
||||
assert_eq!(order, vec!["gone.txt", "keep.txt"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unscanned_covers_a_path_and_what_is_under_it_but_not_its_siblings() {
|
||||
let mut unscanned = Unscanned::default();
|
||||
unscanned.unreadable("a/locked".to_string(), "Permission denied");
|
||||
unscanned.vanished("b.txt".to_string());
|
||||
|
||||
assert!(unscanned.covers("a/locked"));
|
||||
assert!(unscanned.covers("a/locked/x/y.txt"));
|
||||
assert!(unscanned.covers("b.txt"));
|
||||
assert!(!unscanned.covers("a"));
|
||||
assert!(!unscanned.covers("a/locked2"));
|
||||
assert!(!unscanned.covers("b.txt.bak"));
|
||||
// A file that only vanished mid-scan is not worth telling anyone about.
|
||||
assert_eq!(unscanned.report(), vec!["a/locked (Permission denied)"]);
|
||||
}
|
||||
|
||||
/// B197: a push pass read its deletions from the raw baseline, so a path
|
||||
/// synced before it became ignored, which the walk skips, was trashed on
|
||||
/// Drive, and the switch the push pass ran for then evicted the local copy.
|
||||
#[test]
|
||||
fn a_push_pass_never_reads_an_ignored_path_as_deleted() {
|
||||
let mut baseline = baseline_with("notes.md", 1, 1);
|
||||
baseline.extend(baseline_with("node_modules", 0, 0));
|
||||
baseline.extend(baseline_with("node_modules/x/index.js", 1, 1));
|
||||
baseline.extend(baseline_with("old.md", 1, 1));
|
||||
let local: HashMap<String, LocalItem> = [("notes.md".to_string(), local_item(false))]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let rules = rules_for(&["node_modules/"]);
|
||||
|
||||
let tracked = tracked_baseline(&baseline, &rules, &Unscanned::default());
|
||||
|
||||
assert_eq!(local_deletions(&tracked, &local), vec!["old.md"]);
|
||||
}
|
||||
|
||||
/// Deletions come out shallowest first, so a trashed folder can take its
|
||||
/// children with it.
|
||||
#[test]
|
||||
fn local_deletions_come_out_shallowest_first() {
|
||||
let mut baseline = baseline_with("d/e/f.txt", 1, 1);
|
||||
baseline.extend(baseline_with("d", 0, 0));
|
||||
baseline.extend(baseline_with("d/e", 0, 0));
|
||||
let local: HashMap<String, LocalItem> = HashMap::new();
|
||||
let tracked = tracked_baseline(&baseline, &rules_for(&[]), &Unscanned::default());
|
||||
|
||||
assert_eq!(
|
||||
local_deletions(&tracked, &local),
|
||||
vec!["d", "d/e", "d/e/f.txt"]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2593,6 +2952,94 @@ mod tests {
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
|
||||
// ---- B198: going online-only never evicts what Drive does not have -----
|
||||
|
||||
#[test]
|
||||
fn a_fully_synced_mirror_folder_may_go_online_only() {
|
||||
let (root, baseline) = synced_subtree("evict-synced");
|
||||
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
|
||||
/// The case that lost data: `.git/` is ignored by default, so no pass ever
|
||||
/// uploads it, and the eviction deleted it with the rest.
|
||||
#[test]
|
||||
fn an_ignored_folder_only_this_disk_has_holds_the_switch_back() {
|
||||
let (root, baseline) = synced_subtree("evict-ignored");
|
||||
std::fs::create_dir(root.join(".git")).unwrap();
|
||||
std::fs::write(root.join(".git/HEAD"), b"ref").unwrap();
|
||||
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[".git/"]));
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
let reason = reason.expect("an ignored folder is only on this disk");
|
||||
assert!(
|
||||
reason.contains(".git") && reason.contains("excluded"),
|
||||
"{reason}"
|
||||
);
|
||||
}
|
||||
|
||||
/// An ignored row stays in the baseline for good once its file is gone, so
|
||||
/// counting it as a deletion not carried up would hold the switch forever.
|
||||
#[test]
|
||||
fn a_deleted_ignored_path_does_not_hold_the_switch_back() {
|
||||
let (root, mut baseline) = synced_subtree("evict-ignored-gone");
|
||||
baseline.insert(
|
||||
"build/out.o".to_string(),
|
||||
StoredSyncEntry {
|
||||
rel_path: "build/out.o".to_string(),
|
||||
..baseline["dir/a.txt"].clone()
|
||||
},
|
||||
);
|
||||
let rules = rules_for(&["build/"]);
|
||||
let ignored = mirror_tree_unsynced(&root, &baseline, &rules);
|
||||
let tracked = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert_eq!(ignored, None);
|
||||
assert!(tracked.unwrap().contains("build/out.o"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_after_the_last_pass_holds_the_switch_back() {
|
||||
let (root, baseline) = synced_subtree("evict-edit");
|
||||
std::fs::write(root.join("dir/sub/b.txt"), b"edited since").unwrap();
|
||||
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert!(reason.unwrap().contains("dir/sub/b.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_file_at_the_root_holds_the_switch_back() {
|
||||
let (root, baseline) = synced_subtree("evict-new");
|
||||
std::fs::write(root.join("new.txt"), b"n").unwrap();
|
||||
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert!(reason.unwrap().contains("new.txt"));
|
||||
}
|
||||
|
||||
/// The folder B196 now leaves out of the pass instead of failing it is,
|
||||
/// by the same token, something the eviction must not delete.
|
||||
#[test]
|
||||
fn an_unreadable_folder_holds_the_switch_back() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let (root, baseline) = synced_subtree("evict-eacces");
|
||||
let locked = root.join("dir/sub");
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert!(reason.unwrap().contains("dir/sub"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symlink_holds_the_switch_back() {
|
||||
let (root, baseline) = synced_subtree("evict-symlink");
|
||||
std::os::unix::fs::symlink("dir/a.txt", root.join("link")).unwrap();
|
||||
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert!(reason.unwrap().contains("link"));
|
||||
}
|
||||
|
||||
/// 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]
|
||||
@@ -2607,7 +3054,14 @@ mod tests {
|
||||
let writing = HashSet::new();
|
||||
let mut out = HashMap::new();
|
||||
let mut progress = no_progress();
|
||||
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
|
||||
let result = walk_local_tree(
|
||||
&root,
|
||||
&rules,
|
||||
&writing,
|
||||
&mut out,
|
||||
&mut Unscanned::default(),
|
||||
&mut progress,
|
||||
);
|
||||
|
||||
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
@@ -2628,30 +3082,40 @@ mod tests {
|
||||
let writing = HashSet::new();
|
||||
let mut out = HashMap::new();
|
||||
let mut progress = no_progress();
|
||||
assert!(walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress).is_err());
|
||||
assert!(
|
||||
walk_local_tree(
|
||||
&root,
|
||||
&rules,
|
||||
&writing,
|
||||
&mut out,
|
||||
&mut Unscanned::default(),
|
||||
&mut progress
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
/// A name that is not valid UTF-8 cannot be keyed into the scan, so it
|
||||
/// cannot be compared against the baseline either. Refusing the pass is the
|
||||
/// only safe reading: skipping it would classify the file as deleted.
|
||||
/// A name that is not valid UTF-8 cannot be keyed into the scan, but it
|
||||
/// cannot be in the baseline or on Drive either, so leaving it out reads as
|
||||
/// the deletion of nothing. It used to fail the pass (B196).
|
||||
#[test]
|
||||
fn a_non_utf8_name_fails_the_scan() {
|
||||
fn a_non_utf8_name_is_left_out_and_reported() {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::unix::ffi::OsStrExt as _;
|
||||
|
||||
let root = sync_test_dir("scan-non-utf8");
|
||||
std::fs::write(root.join(OsStr::from_bytes(b"bad\xffname")), b"x").unwrap();
|
||||
std::fs::write(root.join("good.txt"), b"y").unwrap();
|
||||
|
||||
let rules = rules_for(&[]);
|
||||
let writing = HashSet::new();
|
||||
let mut out = HashMap::new();
|
||||
let mut progress = no_progress();
|
||||
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
|
||||
let result = scan(&root);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
|
||||
let error = result.expect_err("a name the scan cannot key must abort the pass");
|
||||
assert!(error.contains("UTF-8"), "{error}");
|
||||
let (out, unscanned) = result.unwrap();
|
||||
assert_eq!(sorted_keys(&out), vec!["good.txt"]);
|
||||
let report = unscanned.report();
|
||||
assert_eq!(report.len(), 1);
|
||||
assert!(report[0].contains("UTF-8"), "{report:?}");
|
||||
}
|
||||
|
||||
/// The complement: a scan that *can* read everything reports everything, so
|
||||
@@ -2670,9 +3134,14 @@ mod tests {
|
||||
let writing = HashSet::new();
|
||||
let mut out = HashMap::new();
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut |rel| {
|
||||
seen.push(rel.to_string())
|
||||
});
|
||||
let result = walk_local_tree(
|
||||
&root,
|
||||
&rules,
|
||||
&writing,
|
||||
&mut out,
|
||||
&mut Unscanned::default(),
|
||||
&mut |rel| seen.push(rel.to_string()),
|
||||
);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
|
||||
result.unwrap();
|
||||
|
||||
@@ -57,9 +57,10 @@ pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) -> Option<std::thread::Jo
|
||||
fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
||||
// 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: Mutex<Vec<(PathBuf, i64)>> = Mutex::new(Vec::new());
|
||||
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 };
|
||||
@@ -67,7 +68,8 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
||||
if matches!(event.kind, notify::EventKind::Access(_)) {
|
||||
return;
|
||||
}
|
||||
let _ = tx_events.send(SyncMsg::ReconcileAll);
|
||||
let msg = event_target(&watched_events.lock(), &event.paths);
|
||||
let _ = tx_events.send(msg);
|
||||
}) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
@@ -256,8 +258,31 @@ fn reconcile_all(core: &Core) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// out of date), falls back to every folder.
|
||||
pub(super) fn event_target(watched: &[(PathBuf, i64)], paths: &[PathBuf]) -> SyncMsg {
|
||||
let mut ids = paths.iter().map(|path| {
|
||||
watched
|
||||
.iter()
|
||||
.filter(|(root, _)| path.starts_with(root))
|
||||
// The deepest root, should one mirror folder sit inside another.
|
||||
.max_by_key(|(root, _)| root.components().count())
|
||||
.map(|(_, id)| *id)
|
||||
});
|
||||
match ids.next() {
|
||||
Some(Some(first)) if ids.all(|id| id == Some(first)) => SyncMsg::Reconcile(first),
|
||||
_ => SyncMsg::ReconcileAll,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring the filesystem watches in line with the current mirror-folder set:
|
||||
/// watch newly-added folders, drop removed ones.
|
||||
///
|
||||
/// 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
|
||||
/// its event thread and waits for the answer would deadlock against it.
|
||||
fn rewatch(
|
||||
core: &Core,
|
||||
watcher: &mut notify::RecommendedWatcher,
|
||||
@@ -274,7 +299,7 @@ fn rewatch(
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut have = watched.lock();
|
||||
let mut have = watched.lock().clone();
|
||||
// Drop watches no longer wanted.
|
||||
have.retain(|(path, _)| {
|
||||
if want.iter().any(|(p, _)| p == path) {
|
||||
@@ -294,6 +319,7 @@ fn rewatch(
|
||||
Err(e) => warn!(path = %path.display(), error = %e, "sync: watch failed"),
|
||||
}
|
||||
}
|
||||
*watched.lock() = have;
|
||||
}
|
||||
|
||||
// ---- reconcile ------------------------------------------------------------
|
||||
@@ -98,6 +98,11 @@ pub(super) struct Outcome {
|
||||
pub(super) errors: usize,
|
||||
/// Files skipped because another process held them open for writing.
|
||||
pub(super) deferred: usize,
|
||||
/// Paths the local scan could not read, left out of the pass. Not part of
|
||||
/// [`is_empty`](Self::is_empty) or the summary: an unreadable folder stays
|
||||
/// unreadable from one poll to the next, so the pass reports it once, when
|
||||
/// the set changes, rather than on every poll.
|
||||
pub(super) unscanned: Vec<String>,
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
|
||||
@@ -90,6 +90,96 @@ pub(super) fn filter_baseline<'a, B>(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The paths a local scan left out because it could not read them: a folder
|
||||
/// it has no permission for, an entry that vanished between the listing and
|
||||
/// its `stat`, a name that is not UTF-8.
|
||||
///
|
||||
/// A left-out path is not a deleted one. A pass drops everything an entry
|
||||
/// covers from classification, exactly as it drops ignored paths, so a
|
||||
/// baseline row under an unreadable folder is neither read as a local deletion
|
||||
/// nor answered with a download into a folder the pass cannot write. Failing
|
||||
/// the whole pass instead is what B55 did, and it left one root-owned folder
|
||||
/// stopping every other file in the tree from syncing (B196).
|
||||
#[derive(Default)]
|
||||
pub(super) struct Unscanned {
|
||||
covered: HashSet<String>,
|
||||
/// The subset worth telling the user about, with why: an entry that only
|
||||
/// vanished mid-scan is not, since the next pass sees it settled.
|
||||
unreadable: Vec<String>,
|
||||
}
|
||||
|
||||
impl Unscanned {
|
||||
/// Leave out `rel`, which vanished while the scan looked at it.
|
||||
pub(super) fn vanished(&mut self, rel: String) {
|
||||
self.covered.insert(rel);
|
||||
}
|
||||
|
||||
/// Leave out `rel`, which cannot be read for `reason`.
|
||||
pub(super) fn unreadable(&mut self, rel: String, reason: &str) {
|
||||
self.unreadable.push(format!("{rel} ({reason})"));
|
||||
self.covered.insert(rel);
|
||||
}
|
||||
|
||||
/// Whether `rel` is a left-out path or lies under one.
|
||||
pub(super) fn covers(&self, rel: &str) -> bool {
|
||||
if self.covered.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut at = rel;
|
||||
loop {
|
||||
if self.covered.contains(at) {
|
||||
return true;
|
||||
}
|
||||
match at.rfind('/') {
|
||||
Some(i) => at = &at[..i],
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The unreadable paths with their reasons, sorted, for the activity feed.
|
||||
pub(super) fn report(&self) -> Vec<String> {
|
||||
let mut lines = self.unreadable.clone();
|
||||
lines.sort_unstable();
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
/// The baseline paths a pass can see: [`filter_baseline`], minus everything
|
||||
/// under a path the scan could not read. This is the set [`guard_local_wipe`]
|
||||
/// checks and the set a push pass reads deletions from, since a path outside
|
||||
/// it is absent from the local walk by rule or by failure, never by loss.
|
||||
pub(super) fn tracked_baseline<'a, B>(
|
||||
baseline: &'a HashMap<String, B>,
|
||||
rules: &IgnoreRules,
|
||||
unscanned: &Unscanned,
|
||||
) -> HashMap<String, &'a B> {
|
||||
let mut tracked = filter_baseline(baseline, rules);
|
||||
tracked.retain(|rel, _| !unscanned.covers(rel));
|
||||
tracked
|
||||
}
|
||||
|
||||
/// The baseline paths a push pass has to trash on Drive: tracked, and gone
|
||||
/// from the local walk. Shallowest first, so a trashed folder takes its
|
||||
/// children with it.
|
||||
///
|
||||
/// Built from [`tracked_baseline`] rather than from the raw baseline. Reading
|
||||
/// it raw trashed every synced path that later became ignored, because the walk
|
||||
/// skips those, and the switch to on-demand that the push pass runs for then
|
||||
/// deleted the local copy too (B197).
|
||||
pub(super) fn local_deletions<B, L>(
|
||||
tracked: &HashMap<String, &B>,
|
||||
local: &HashMap<String, L>,
|
||||
) -> Vec<String> {
|
||||
let mut missing: Vec<String> = tracked
|
||||
.keys()
|
||||
.filter(|rel| !local.contains_key(*rel))
|
||||
.cloned()
|
||||
.collect();
|
||||
missing.sort_by_key(|p| p.matches('/').count());
|
||||
missing
|
||||
}
|
||||
|
||||
/// Refuse to run a pass whose local side has vanished in its entirety.
|
||||
///
|
||||
/// When every baseline path is absent locally, the likely cause is an unavailable
|
||||
|
||||
@@ -46,6 +46,8 @@ struct JobEntry {
|
||||
detail: Mutex<String>,
|
||||
done: AtomicU64,
|
||||
total: AtomicU64,
|
||||
/// Housekeeping that moves nothing; see [`JobItem::background`].
|
||||
background: bool,
|
||||
}
|
||||
|
||||
/// The set of transfers and jobs currently in flight. Cloned `Arc`-style across
|
||||
@@ -160,12 +162,25 @@ impl TransferRegistry {
|
||||
/// that carries its progress and deregisters the job when dropped. The job
|
||||
/// starts indeterminate; give it a `total` once one is known.
|
||||
pub fn begin_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard {
|
||||
self.register_job(title.into(), false)
|
||||
}
|
||||
|
||||
/// [`TransferRegistry::begin_job`] for housekeeping the user did not ask
|
||||
/// for and that changes nothing in their files, such as an index rebuild or
|
||||
/// a cache check. It is reported, but front ends don't call the drive busy
|
||||
/// over it.
|
||||
pub fn begin_background_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard {
|
||||
self.register_job(title.into(), true)
|
||||
}
|
||||
|
||||
fn register_job(self: &Arc<Self>, title: String, background: bool) -> JobGuard {
|
||||
let id = self.next.fetch_add(1, Ordering::Relaxed);
|
||||
let entry = Arc::new(JobEntry {
|
||||
title: title.into(),
|
||||
title,
|
||||
detail: Mutex::new(String::new()),
|
||||
done: AtomicU64::new(0),
|
||||
total: AtomicU64::new(0),
|
||||
background,
|
||||
});
|
||||
self.jobs.lock().insert(id, entry.clone());
|
||||
JobGuard {
|
||||
@@ -191,6 +206,7 @@ impl TransferRegistry {
|
||||
detail: e.detail.lock().clone(),
|
||||
done: e.done.load(Ordering::Relaxed),
|
||||
total: e.total.load(Ordering::Relaxed),
|
||||
background: e.background,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<!-- Main menu: three bars. Bundled because themes such as Papirus draw
|
||||
open-menu-symbolic as three dots, the same as every row's "more" menu. -->
|
||||
<path fill="#222222" d="M 2,2.5 H 14 A 1,1 0 0 1 14,4.5 H 2 A 1,1 0 0 1 2,2.5 Z M 2,7 H 14 A 1,1 0 0 1 14,9 H 2 A 1,1 0 0 1 2,7 Z M 2,11.5 H 14 A 1,1 0 0 1 14,13.5 H 2 A 1,1 0 0 1 2,11.5 Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 441 B |
@@ -14,7 +14,6 @@
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-details-pane-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-link-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-map-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-menu-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-people-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-share-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-sync-symbolic.svg</file>
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
.sidebar-status image.warning { color: var(--warning-color); }
|
||||
.sidebar-status image.error { color: var(--error-color); }
|
||||
.sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; }
|
||||
.sidebar-account { padding: 4px 4px 4px 8px; }
|
||||
/* The account row is the main menu's button: a row, not a pushbutton, so
|
||||
* the name keeps the text weight. */
|
||||
button.sidebar-account { padding: 4px 8px; font-weight: normal; }
|
||||
/* A count of what waits on a page, at the end of its sidebar row */
|
||||
.sidebar-badge {
|
||||
min-width: 10px;
|
||||
@@ -48,6 +50,9 @@
|
||||
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
|
||||
.badge-issue { color: var(--error-color); }
|
||||
.browser-statusbar { background-color: alpha(currentColor, 0.025); }
|
||||
/* The folder on screen, last in the breadcrumb trail: a flat button's side
|
||||
* padding, so the "›" before it is spaced like the ones between buttons. */
|
||||
.crumb-current { padding: 0 10px; }
|
||||
scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
|
||||
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
|
||||
.dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
|
||||
|
||||
@@ -34,6 +34,7 @@ use pages::verify::*;
|
||||
use reload::{LoadTicket, Loader, replace_items};
|
||||
use theme::{load_resources, proton_theme_active, set_proton_theme};
|
||||
use widgets::compat::*;
|
||||
use widgets::crumb::*;
|
||||
use widgets::details::*;
|
||||
use widgets::file_list::*;
|
||||
use widgets::menu::*;
|
||||
@@ -202,12 +203,20 @@ impl Ui {
|
||||
}
|
||||
|
||||
fn main() -> glib::ExitCode {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
let verify = std::env::args().nth(1).as_deref() == Some(VERIFY_ARG);
|
||||
let logs = tracing_subscriber::fmt().with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
);
|
||||
// The verification window's stdout carries its token back to the app.
|
||||
if verify {
|
||||
logs.with_writer(std::io::stderr).init();
|
||||
} else {
|
||||
logs.init();
|
||||
}
|
||||
i18n::init();
|
||||
if verify {
|
||||
return run_verification_window();
|
||||
}
|
||||
|
||||
// The tray launches the app with arguments ("--page locations",
|
||||
// "--confirm-stop"), which a running instance must receive too.
|
||||
@@ -303,6 +312,11 @@ fn build_window(app: &adw::Application) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
// On a fresh install the daemon has not run yet to make these, and the
|
||||
// Takeout page keeps its staged archives in the state dir (B194).
|
||||
if let Err(e) = dirs.ensure() {
|
||||
tracing::warn!("cannot create the app's directories: {e}");
|
||||
}
|
||||
|
||||
let stack = adw::ViewStack::new();
|
||||
let (login_page, login_widgets) = build_login_page();
|
||||
@@ -524,6 +538,7 @@ fn build_window(app: &adw::Application) {
|
||||
reload_pending: Cell::new(false),
|
||||
loading: Cell::new(false),
|
||||
has_more: Cell::new(false),
|
||||
library: Cell::new(true),
|
||||
page_waiters: RefCell::new(Vec::new()),
|
||||
width: Cell::new(0),
|
||||
photo_tex: RefCell::new(HashMap::new()),
|
||||
@@ -565,6 +580,8 @@ fn build_window(app: &adw::Application) {
|
||||
detail: locations_widgets.card_detail.clone(),
|
||||
pause: locations_widgets.pause.clone(),
|
||||
paused: Cell::new(false),
|
||||
sign_in: locations_widgets.card_sign_in.clone(),
|
||||
restart: locations_widgets.card_restart.clone(),
|
||||
},
|
||||
queue: QueueState {
|
||||
group: locations_widgets.queue_group.clone(),
|
||||
@@ -817,7 +834,6 @@ fn build_sidebar(footer: >k4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
|
||||
|
||||
let header = adw::HeaderBar::new();
|
||||
header.set_title_widget(Some(&brand));
|
||||
header.pack_end(&build_primary_menu());
|
||||
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
@@ -1140,25 +1156,7 @@ fn toast_failure(ui: &Rc<Ui>, what: &str, message: &str, kind: ErrorKind) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The sidebar's primary (hamburger) menu: the app-level entries that don't
|
||||
/// belong on any one page.
|
||||
fn build_primary_menu() -> gtk4::MenuButton {
|
||||
let menu = gio::Menu::new();
|
||||
menu.append(Some(&gettext("Preferences")), Some("win.preferences"));
|
||||
menu.append(Some(&gettext("Keyboard Shortcuts")), Some("win.shortcuts"));
|
||||
menu.append(
|
||||
Some(&gettext("About Proton Drive for Linux")),
|
||||
Some("win.about"),
|
||||
);
|
||||
gtk4::MenuButton::builder()
|
||||
.icon_name("pdfs-menu-symbolic")
|
||||
.tooltip_text(gettext("Main menu"))
|
||||
.primary(true)
|
||||
.menu_model(&menu)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Back the primary and account menus' entries with window actions.
|
||||
/// Back the main menu's entries with window actions.
|
||||
fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
||||
let preferences = gio::SimpleAction::new("preferences", None);
|
||||
let ui_prefs = ui.clone();
|
||||
@@ -1210,7 +1208,7 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
||||
let dialog = adw::AboutDialog::builder()
|
||||
.application_name(gettext("Proton Drive for Linux"))
|
||||
.application_icon("io.narl.proton-drive-linux")
|
||||
.version(pdfs_core::config::APP_VERSION)
|
||||
.version(env!("CARGO_PKG_VERSION"))
|
||||
.developer_name("Nils Pukropp")
|
||||
.website("https://proton-drive.narl.io")
|
||||
.issue_url("https://github.com/narrrl/proton-drive-linux/issues")
|
||||
@@ -1295,6 +1293,12 @@ fn install_launch_actions(ui: &Rc<Ui>, app: &adw::Application, window: &adw::App
|
||||
if ui_page.session.borrow().is_none() {
|
||||
return;
|
||||
}
|
||||
// Signed in, but Proton refused the session (the tray's "Sign In
|
||||
// Again…"): the login page, with the rest of the app still reachable.
|
||||
if page == "login" {
|
||||
show_login(&ui_page);
|
||||
return;
|
||||
}
|
||||
// Activity lives on as the Sync page's History view.
|
||||
if page == "activity" {
|
||||
ui_page.locations.views.set_visible_child_name("history");
|
||||
@@ -1350,8 +1354,8 @@ fn debug_info(ui: &Rc<Ui>) -> String {
|
||||
format!(
|
||||
"App version: {}\nUser agent: {}\nlibadwaita: {}.{}.{}\nGTK: {}.{}.{}\n\
|
||||
Mountpoint: {}\nControl socket: {}\nMount service running: {}\n",
|
||||
pdfs_core::config::APP_VERSION,
|
||||
pdfs_core::config::USER_AGENT,
|
||||
config.resolved_app_version(),
|
||||
config.resolved_user_agent(),
|
||||
adw::major_version(),
|
||||
adw::minor_version(),
|
||||
adw::micro_version(),
|
||||
|
||||
@@ -520,9 +520,9 @@ pub(crate) fn clear_selection(ui: &Rc<Ui>) {
|
||||
}
|
||||
|
||||
/// Reflect the current selection in the bulk bar: how many are selected, and
|
||||
/// which bulk actions apply to that mix. Offline state is a file-only notion, so
|
||||
/// a folder anywhere in the selection disables the two pin buttons rather than
|
||||
/// half-failing once pressed.
|
||||
/// which bulk actions apply to that mix. An entry kept offline through a pinned
|
||||
/// folder above it cannot be made online only on its own, so it does not count
|
||||
/// towards Make online only.
|
||||
pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
|
||||
let entries = selected_entries(ui);
|
||||
// One selected entry is the details pane's job; the bar is for a batch.
|
||||
@@ -530,8 +530,7 @@ pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
|
||||
ui.browser.bulk.set_reveal_child(false);
|
||||
return;
|
||||
}
|
||||
let files_only = entries.iter().all(|e| !e.is_dir);
|
||||
let any_pinned = entries.iter().any(|e| e.pinned);
|
||||
let any_pinned = entries.iter().any(|e| e.pinned && !e.pinned_by_folder);
|
||||
let any_unpinned = entries.iter().any(|e| !e.pinned);
|
||||
let mounted = *ui.mounted.borrow();
|
||||
ui.browser.bulk_label.set_label(&ngettext_f(
|
||||
@@ -541,12 +540,8 @@ pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
|
||||
&[],
|
||||
));
|
||||
ui.browser.bulk_trash.set_sensitive(mounted);
|
||||
ui.browser
|
||||
.bulk_pin
|
||||
.set_sensitive(mounted && files_only && any_unpinned);
|
||||
ui.browser
|
||||
.bulk_unpin
|
||||
.set_sensitive(mounted && files_only && any_pinned);
|
||||
ui.browser.bulk_pin.set_sensitive(mounted && any_unpinned);
|
||||
ui.browser.bulk_unpin.set_sensitive(mounted && any_pinned);
|
||||
ui.browser.bulk.set_reveal_child(true);
|
||||
}
|
||||
|
||||
@@ -652,16 +647,19 @@ pub(crate) fn restore_uids(ui: &Rc<Ui>, uids: Vec<String>, count: usize) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Pin or unpin every selected file, one request at a time.
|
||||
/// Pin or unpin every selected entry, one request at a time. A folder's files
|
||||
/// download in the background, so its request returns before they are there.
|
||||
pub(crate) fn run_bulk_pin(ui: &Rc<Ui>, entries: Vec<DirEntry>, pin: bool) {
|
||||
let socket = ui.dirs.control_socket();
|
||||
// Only the entries that need changing: re-pinning an already-pinned file is
|
||||
// a wasted round-trip, and it would inflate the count the toast reports.
|
||||
let paths: Vec<String> = entries
|
||||
// a wasted round-trip, and it would inflate the count the toast reports. One
|
||||
// kept through a pinned folder above it has no pin of its own to remove.
|
||||
let changing: Vec<&DirEntry> = entries
|
||||
.iter()
|
||||
.filter(|e| !e.is_dir && e.pinned != pin)
|
||||
.map(|e| entry_rel(ui, e))
|
||||
.filter(|e| !e.pinned_by_folder && e.pinned != pin)
|
||||
.collect();
|
||||
let any_folder = changing.iter().any(|e| e.is_dir);
|
||||
let paths: Vec<String> = changing.iter().map(|e| entry_rel(ui, e)).collect();
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -698,13 +696,27 @@ pub(crate) fn run_bulk_pin(ui: &Rc<Ui>, entries: Vec<DirEntry>, pin: bool) {
|
||||
(0, None) => {}
|
||||
(n, _) => toast(
|
||||
&ui,
|
||||
&if pin {
|
||||
&if pin && any_folder {
|
||||
ngettext_f(
|
||||
"Making {n} item available offline…",
|
||||
"Making {n} items available offline…",
|
||||
n as u64,
|
||||
&[],
|
||||
)
|
||||
} else if pin {
|
||||
ngettext_f(
|
||||
"{n} file is now available offline",
|
||||
"{n} files are now available offline",
|
||||
n as u64,
|
||||
&[],
|
||||
)
|
||||
} else if any_folder {
|
||||
ngettext_f(
|
||||
"{n} item is now online only",
|
||||
"{n} items are now online only",
|
||||
n as u64,
|
||||
&[],
|
||||
)
|
||||
} else {
|
||||
ngettext_f(
|
||||
"{n} file is now online only",
|
||||
@@ -860,13 +872,17 @@ pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||
}
|
||||
menu.section();
|
||||
|
||||
if !entry.is_dir {
|
||||
// Kept through a pinned folder above it, the entry has no pin of its own
|
||||
// to remove: the check shows that state without offering to change it.
|
||||
if entry.pinned_by_folder {
|
||||
menu.locked_toggle(&gettext("Available offline"), true);
|
||||
} else {
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
menu.toggle(&gettext("Available offline"), entry.pinned, move |_| {
|
||||
toggle_pin(&ui_c, &entry_c)
|
||||
});
|
||||
menu.section();
|
||||
}
|
||||
menu.section();
|
||||
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
menu.item(&gettext("Share…"), move || {
|
||||
@@ -908,9 +924,8 @@ pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||
/// simply absent here rather than offered and then refused.
|
||||
pub(crate) fn bulk_context_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu {
|
||||
let mut menu = ActionMenu::new();
|
||||
// Offline state is a file-only notion; the check shows whether the whole
|
||||
// batch already is.
|
||||
if entries.iter().all(|e| !e.is_dir) {
|
||||
// The check shows whether the whole batch already is offline.
|
||||
{
|
||||
let (ui_c, batch) = (ui.clone(), entries.clone());
|
||||
let all_pinned = entries.iter().all(|e| e.pinned);
|
||||
menu.toggle(&gettext("Available offline"), all_pinned, move |pin| {
|
||||
@@ -1147,6 +1162,7 @@ pub(crate) fn toggle_pin(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let rx = spawn_request(ui.dirs.control_socket(), req);
|
||||
let name = entry.name.clone();
|
||||
let pinned = entry.pinned;
|
||||
let is_dir = entry.is_dir;
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
@@ -1166,6 +1182,9 @@ pub(crate) fn toggle_pin(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
&ui,
|
||||
&if pinned {
|
||||
gettext_f("“{name}” is now online only", &[("name", &name)])
|
||||
} else if is_dir {
|
||||
// The daemon downloads a folder's files in the background.
|
||||
gettext_f("Making “{name}” available offline…", &[("name", &name)])
|
||||
} else {
|
||||
gettext_f("“{name}” is now available offline", &[("name", &name)])
|
||||
},
|
||||
@@ -1220,9 +1239,7 @@ pub(crate) fn repaint_crumb(ui: &Rc<Ui>, path: &str) {
|
||||
));
|
||||
let mut acc = String::new();
|
||||
for (i, seg) in segments.iter().enumerate() {
|
||||
let sep = gtk4::Label::new(Some("›"));
|
||||
sep.add_css_class("dim-label");
|
||||
ui.browser.crumb.append(&sep);
|
||||
ui.browser.crumb.append(&crumb_separator());
|
||||
acc = if acc.is_empty() {
|
||||
seg.to_string()
|
||||
} else {
|
||||
@@ -1237,12 +1254,7 @@ pub(crate) fn repaint_crumb(ui: &Rc<Ui>, path: &str) {
|
||||
/// flat button that navigates to `target` (clearing any active search first).
|
||||
pub(crate) fn crumb_node(ui: &Rc<Ui>, label: &str, target: &str, current: bool) -> gtk4::Widget {
|
||||
if current {
|
||||
let l = gtk4::Label::builder()
|
||||
.label(label)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
||||
.build();
|
||||
l.add_css_class("heading");
|
||||
return l.upcast();
|
||||
return crumb_current(label);
|
||||
}
|
||||
let button = gtk4::Button::builder().label(label).build();
|
||||
button.add_css_class("flat");
|
||||
@@ -2494,16 +2506,19 @@ pub(crate) fn start_upload(ui: &Rc<Ui>, sources: Vec<String>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The sync-state badge for an entry: `(icon, css-class)`, or `None` for folders
|
||||
/// (which carry no per-file cache state). A sync issue ranks first, on folders
|
||||
/// too: the change waits for the user. Pinned (kept offline) ranks above merely
|
||||
/// cached (downloaded, evictable); everything else is online-only.
|
||||
/// The sync-state badge for an entry: `(icon, css-class)`. A folder carries no
|
||||
/// cache state of its own, so it has one only when kept offline. A sync issue
|
||||
/// ranks first, on folders too: the change waits for the user. Pinned (kept
|
||||
/// offline) ranks above merely cached (downloaded, evictable); everything else
|
||||
/// is online-only.
|
||||
pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)> {
|
||||
if entry.issue.is_some() {
|
||||
return Some(("dialog-warning-symbolic", "badge-issue"));
|
||||
}
|
||||
if entry.is_dir {
|
||||
return None;
|
||||
return entry
|
||||
.pinned
|
||||
.then_some(("pdfs-offline-symbolic", "badge-pinned"));
|
||||
}
|
||||
Some(if entry.pinned {
|
||||
("pdfs-offline-symbolic", "badge-pinned")
|
||||
@@ -2912,6 +2927,7 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
||||
size: h.size,
|
||||
modified: h.modified,
|
||||
pinned: h.pinned,
|
||||
pinned_by_folder: false,
|
||||
cached: h.cached,
|
||||
uid: h.uid.clone(),
|
||||
path: h.path.clone(),
|
||||
@@ -2972,6 +2988,7 @@ mod tests {
|
||||
size,
|
||||
modified,
|
||||
pinned: false,
|
||||
pinned_by_folder: false,
|
||||
cached: false,
|
||||
uid: String::new(),
|
||||
path: String::new(),
|
||||
@@ -2999,6 +3016,8 @@ mod tests {
|
||||
|
||||
let mut folder = entry("d", true, 0, 0);
|
||||
assert_eq!(badge_for(&folder), None);
|
||||
folder.pinned = true;
|
||||
assert_eq!(badge_for(&folder).unwrap().1, "badge-pinned");
|
||||
folder.issue = Some(SyncIssue::Missing);
|
||||
assert_eq!(badge_for(&folder).unwrap().1, "badge-issue");
|
||||
}
|
||||
|
||||
@@ -439,6 +439,10 @@ fn other_computer_row(ui: &Rc<Ui>, dev: &DeviceInfo) -> adw::ActionRow {
|
||||
menu.item(&gettext("Restore to This Computer…"), move || {
|
||||
prompt_restore_folders(&ui_c, Some((uid_c.clone(), name_c.clone())))
|
||||
});
|
||||
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
||||
menu.item(&gettext("Move to This Computer…"), move || {
|
||||
prompt_migrate_device(&ui_c, &uid_c, &name_c)
|
||||
});
|
||||
menu.section();
|
||||
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
||||
menu.item(&gettext("Rename…"), move || {
|
||||
@@ -483,9 +487,7 @@ fn repaint_device_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
||||
nav.is_empty(),
|
||||
));
|
||||
for (i, (_, name)) in nav.iter().enumerate() {
|
||||
let sep = gtk4::Label::new(Some("›"));
|
||||
sep.add_css_class("dim-label");
|
||||
crumb.append(&sep);
|
||||
crumb.append(&crumb_separator());
|
||||
crumb.append(&device_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
|
||||
}
|
||||
}
|
||||
@@ -494,12 +496,7 @@ fn repaint_device_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
||||
/// that goes back up to `depth` levels below the device list.
|
||||
fn device_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
|
||||
if current {
|
||||
let l = gtk4::Label::builder()
|
||||
.label(label)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
||||
.build();
|
||||
l.add_css_class("heading");
|
||||
return l.upcast();
|
||||
return crumb_current(label);
|
||||
}
|
||||
let button = gtk4::Button::builder().label(label).build();
|
||||
button.add_css_class("flat");
|
||||
@@ -1096,6 +1093,67 @@ pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Ask before moving another computer's folders into this computer's backup,
|
||||
/// then offer them for syncing here.
|
||||
///
|
||||
/// Destructive-looking on purpose: the other computer keeps its device but
|
||||
/// loses its folders, and a copy of Proton Drive still running there would
|
||||
/// watch them vanish.
|
||||
pub(crate) fn prompt_migrate_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Move Backup to This Computer?"))
|
||||
// Translators: {name} is the name of the other computer whose folders move to this one.
|
||||
.body(gettext_f(
|
||||
"The folders backed up by “{name}” move into this computer's backup in Proton Drive, and you choose where they go on this computer. “{name}” stays in your account, without those folders.\n\nStop Proton Drive on “{name}” first. Otherwise it sees its folders disappear.",
|
||||
&[("name", name)],
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("move", &gettext("Move Here"));
|
||||
dialog.set_response_appearance("move", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
let ui = ui.clone();
|
||||
let uid = uid.to_string();
|
||||
// Translators: {name} is the name of the computer whose folders moved to this one.
|
||||
let done = gettext_f("Moved the backup of “{name}”", &[("name", name)]);
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
if resp != "move" {
|
||||
return;
|
||||
}
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::MigrateDevice { uid: uid.clone() },
|
||||
);
|
||||
let (ui, done) = (ui.clone(), done.clone());
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
let failed = gettext("Couldn't move that backup");
|
||||
match result {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
reload_sync_pages(&ui);
|
||||
toast(&ui, &done);
|
||||
// The folders are this computer's now, so its own restore
|
||||
// picker is where the user chooses their local paths.
|
||||
prompt_restore_folders(&ui, None);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, &failed, &message, kind)
|
||||
}
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
&failed,
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Run a mutation raised from Computers or Locations and reload the page on success.
|
||||
pub(crate) fn run_devices_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed: &str) {
|
||||
ui.busy_begin();
|
||||
|
||||
@@ -46,6 +46,10 @@ pub(crate) struct SyncCard {
|
||||
pub(crate) pause: adw::SplitButton,
|
||||
/// Whether the last status said paused, so the button knows which way to go.
|
||||
pub(crate) paused: Cell<bool>,
|
||||
/// Shown only while Proton refuses the session; opens the sign-in page.
|
||||
pub(crate) sign_in: gtk4::Button,
|
||||
/// Shown only while the daemon is older than the app; restarts it.
|
||||
pub(crate) restart: gtk4::Button,
|
||||
}
|
||||
|
||||
/// What a folder row's controls are built from. The subtitle and progress bar
|
||||
@@ -108,6 +112,8 @@ pub(crate) struct LocationsWidgets {
|
||||
pub(crate) card_icon: gtk4::Image,
|
||||
pub(crate) card_title: gtk4::Label,
|
||||
pub(crate) card_detail: gtk4::Label,
|
||||
pub(crate) card_sign_in: gtk4::Button,
|
||||
pub(crate) card_restart: gtk4::Button,
|
||||
pub(crate) pause: adw::SplitButton,
|
||||
pub(crate) queue_group: adw::PreferencesGroup,
|
||||
pub(crate) retry_all: gtk4::Button,
|
||||
@@ -134,7 +140,7 @@ pub(crate) fn build_locations_page(history: >k4::Widget) -> (gtk4::Widget, Loc
|
||||
.build();
|
||||
add_folder.add_css_class("flat");
|
||||
let transfers_group = build_transfers_group();
|
||||
let (card, card_icon, card_title, card_detail) = build_sync_card();
|
||||
let (card, card_icon, card_title, card_detail, card_sign_in, card_restart) = build_sync_card();
|
||||
let pause = build_pause_button();
|
||||
let (queue_group, retry_all) = build_queue_group();
|
||||
let conflicts_group = adw::PreferencesGroup::builder()
|
||||
@@ -253,6 +259,8 @@ pub(crate) fn build_locations_page(history: >k4::Widget) -> (gtk4::Widget, Loc
|
||||
card_icon,
|
||||
card_title,
|
||||
card_detail,
|
||||
card_sign_in,
|
||||
card_restart,
|
||||
pause,
|
||||
queue_group,
|
||||
retry_all,
|
||||
@@ -290,6 +298,16 @@ pub(crate) fn wire_locations(ui: &Rc<Ui>, retry: >k4::Button, add_folder: >k
|
||||
let ui_add = ui.clone();
|
||||
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
|
||||
wire_pause(ui);
|
||||
let ui_sign_in = ui.clone();
|
||||
ui.locations
|
||||
.card
|
||||
.sign_in
|
||||
.connect_clicked(move |_| show_login(&ui_sign_in));
|
||||
let ui_restart = ui.clone();
|
||||
ui.locations
|
||||
.card
|
||||
.restart
|
||||
.connect_clicked(move |_| restart_service_then(&ui_restart, |_| {}));
|
||||
let ui_retry = ui.clone();
|
||||
ui.locations
|
||||
.queue
|
||||
@@ -379,9 +397,16 @@ pub(crate) fn paint_sync_banner(ui: &Rc<Ui>) {
|
||||
state.banner.set_revealed(away && title.is_some());
|
||||
}
|
||||
|
||||
/// The status hero: a large state icon, the state in words, and what it
|
||||
/// means.
|
||||
fn build_sync_card() -> (gtk4::Box, gtk4::Image, gtk4::Label, gtk4::Label) {
|
||||
/// The status hero: a large state icon, the state in words, what it means,
|
||||
/// and the way out when there is one only the user can take.
|
||||
fn build_sync_card() -> (
|
||||
gtk4::Box,
|
||||
gtk4::Image,
|
||||
gtk4::Label,
|
||||
gtk4::Label,
|
||||
gtk4::Button,
|
||||
gtk4::Button,
|
||||
) {
|
||||
let icon = gtk4::Image::builder()
|
||||
.icon_name("pdfs-sync-symbolic")
|
||||
.pixel_size(48)
|
||||
@@ -408,9 +433,25 @@ fn build_sync_card() -> (gtk4::Box, gtk4::Image, gtk4::Label, gtk4::Label) {
|
||||
let card = gtk4::Box::new(gtk4::Orientation::Horizontal, 18);
|
||||
card.add_css_class("card");
|
||||
card.add_css_class("sync-card");
|
||||
let sign_in = gtk4::Button::builder()
|
||||
.label(gettext("Sign In Again"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.visible(false)
|
||||
.build();
|
||||
sign_in.add_css_class("pill");
|
||||
sign_in.add_css_class("suggested-action");
|
||||
let restart = gtk4::Button::builder()
|
||||
.label(gettext("Restart"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.visible(false)
|
||||
.build();
|
||||
restart.add_css_class("pill");
|
||||
restart.add_css_class("suggested-action");
|
||||
card.append(&icon);
|
||||
card.append(&text);
|
||||
(card, icon, title, detail)
|
||||
card.append(&sign_in);
|
||||
card.append(&restart);
|
||||
(card, icon, title, detail, sign_in, restart)
|
||||
}
|
||||
|
||||
/// Pause/Resume, with timed pauses in its menu.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::*;
|
||||
use pdfs_core::proton_sdk::api::HumanVerificationCredential;
|
||||
use pdfs_core::proton_sdk::api::{HumanVerification, HumanVerificationCredential};
|
||||
|
||||
pub(crate) struct LoginState {
|
||||
// Login page.
|
||||
@@ -150,15 +150,25 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
});
|
||||
|
||||
// Same shape for human verification: only fires when the API gates the
|
||||
// sign-in, and the worker is blocked on the token until it does.
|
||||
// sign-in, and the worker is blocked on the credential until it does.
|
||||
let ui_hv = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
if let Ok((url, token_tx)) = hv_req_rx.recv().await {
|
||||
if let Ok((hv, credential_tx)) = hv_req_rx.recv().await {
|
||||
ui_hv
|
||||
.login
|
||||
.login_status
|
||||
.set_text(&gettext("Complete the verification to continue…"));
|
||||
prompt_human_verification(&ui_hv, &url, token_tx);
|
||||
// Anything but a credential drops the sender, which the worker
|
||||
// reads as a cancelled sign-in.
|
||||
match verify_human(hv.verification_url()).await {
|
||||
Verification::Solved(token) => {
|
||||
let _ = credential_tx.send(HumanVerificationCredential::captcha(token));
|
||||
}
|
||||
Verification::Cancelled => {}
|
||||
// The web view could not start, most likely its sandbox
|
||||
// (B195): the user's browser can show the page instead.
|
||||
Verification::Failed => verify_in_browser(&ui_hv, &hv, credential_tx),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -217,6 +227,11 @@ pub(crate) fn login_error_message(error: &pdfs_core::Error) -> String {
|
||||
pdfs_core::Error::Proton(ProtonError::Api(api)) if api.http_status == 429 => {
|
||||
gettext("Too many sign-in attempts. Wait a few minutes and try again.")
|
||||
}
|
||||
// Proton's text for this one asks the user to update an app that is
|
||||
// already current.
|
||||
pdfs_core::Error::Proton(e) if pdfs_core::error::sign_in_blocked(e) => gettext(
|
||||
"Proton is temporarily refusing sign-in from this device. Your app version is not the problem. Wait several hours, up to a day, without trying again, then sign in.",
|
||||
),
|
||||
pdfs_core::Error::Proton(ProtonError::Api(api)) if !api.message.is_empty() => {
|
||||
api.message.clone()
|
||||
}
|
||||
@@ -252,9 +267,12 @@ pub(crate) struct LoginChannels {
|
||||
pub(crate) result: async_channel::Receiver<Result<(), String>>,
|
||||
/// Fires only if the account needs a 2FA code; carries the sender for it.
|
||||
pub(crate) totp: async_channel::Receiver<std::sync::mpsc::Sender<String>>,
|
||||
/// Fires only if the sign-in is gated: the page to show, and the sender for
|
||||
/// the token the user earns on it.
|
||||
pub(crate) hv: async_channel::Receiver<(String, std::sync::mpsc::Sender<String>)>,
|
||||
/// Fires only if the sign-in is gated: the challenge, and the sender for the
|
||||
/// credential the user earns by solving it.
|
||||
pub(crate) hv: async_channel::Receiver<(
|
||||
HumanVerification,
|
||||
std::sync::mpsc::Sender<HumanVerificationCredential>,
|
||||
)>,
|
||||
/// Fires only for a two-password account: how many answers were already
|
||||
/// wrong, and the sender for the password and the "remember" choice.
|
||||
pub(crate) mailbox: async_channel::Receiver<(u32, std::sync::mpsc::Sender<(String, bool)>)>,
|
||||
@@ -312,18 +330,16 @@ pub(crate) fn spawn_login(username: String, password: String) -> LoginChannels {
|
||||
})
|
||||
},
|
||||
|hv| {
|
||||
// Gated: hand the UI the page to show, block on the token the
|
||||
// user earns by solving it.
|
||||
let (token_tx, token_rx) = std::sync::mpsc::channel::<String>();
|
||||
hv_req_tx
|
||||
.send_blocking((hv.verification_url(), token_tx))
|
||||
.map_err(|_| {
|
||||
pdfs_core::Error::Other("verification prompt closed".into())
|
||||
})?;
|
||||
let token = token_rx
|
||||
// Gated: hand the UI the challenge, block on the credential
|
||||
// the user earns by solving it.
|
||||
let (credential_tx, credential_rx) =
|
||||
std::sync::mpsc::channel::<HumanVerificationCredential>();
|
||||
hv_req_tx.send_blocking((hv, credential_tx)).map_err(|_| {
|
||||
pdfs_core::Error::Other("verification prompt closed".into())
|
||||
})?;
|
||||
credential_rx
|
||||
.recv()
|
||||
.map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))?;
|
||||
Ok(HumanVerificationCredential::captcha(token))
|
||||
.map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -510,6 +526,63 @@ pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>)
|
||||
dialog.present(parent.as_ref());
|
||||
}
|
||||
|
||||
/// Let the user solve the challenge in their own browser, for when the
|
||||
/// verification window could not come up. The page cannot hand its token back
|
||||
/// to us, so the user says when they are done, and the retry carries the
|
||||
/// challenge token, which the solved page made good (see
|
||||
/// [`auth::browser_verified`]).
|
||||
fn verify_in_browser(
|
||||
ui: &Rc<Ui>,
|
||||
hv: &HumanVerification,
|
||||
credential_tx: std::sync::mpsc::Sender<HumanVerificationCredential>,
|
||||
) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Verify in Your Browser"))
|
||||
.body(gettext(
|
||||
"The verification page can't be shown in the app on this system. Open it in your browser, complete it there, then come back and choose Continue.",
|
||||
))
|
||||
.build();
|
||||
|
||||
let url = auth::browser_verification_url(hv);
|
||||
let open = gtk4::Button::builder()
|
||||
.label(gettext("Open Verification Page"))
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
open.add_css_class("pill");
|
||||
dialog.set_extra_child(Some(&open));
|
||||
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("continue", &gettext("Continue"));
|
||||
dialog.set_response_appearance("continue", adw::ResponseAppearance::Suggested);
|
||||
// Continuing before the page was even opened would only fail the retry.
|
||||
dialog.set_response_enabled("continue", false);
|
||||
dialog.set_default_response(Some("continue"));
|
||||
dialog.set_close_response("cancel");
|
||||
|
||||
let dialog_weak = dialog.downgrade();
|
||||
open.connect_clicked(move |_| {
|
||||
open_uri(&url);
|
||||
if let Some(dialog) = dialog_weak.upgrade() {
|
||||
dialog.set_response_enabled("continue", true);
|
||||
}
|
||||
});
|
||||
|
||||
let credential = auth::browser_verified(hv);
|
||||
let credential_tx = RefCell::new(Some(credential_tx));
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
// On cancel/close we take + drop `tx` without sending, so the worker's
|
||||
// recv errors out and the login is reported as cancelled.
|
||||
if let Some(tx) = credential_tx.borrow_mut().take()
|
||||
&& resp == "continue"
|
||||
{
|
||||
let _ = tx.send(credential.clone());
|
||||
}
|
||||
});
|
||||
|
||||
let parent = ui.login.login_button.root().and_downcast::<gtk4::Window>();
|
||||
dialog.present(parent.as_ref());
|
||||
}
|
||||
|
||||
/// Sign out, after a confirmation: disable+stop the mount service (so the
|
||||
/// daemon isn't left running without credentials), forget the stored session,
|
||||
/// and drop back to the login page.
|
||||
@@ -560,6 +633,21 @@ mod tests {
|
||||
assert!(login_error_message(&error).contains("no mailbox password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blocked_sign_in_does_not_ask_for_an_update() {
|
||||
use pdfs_core::proton_sdk::api::ResponseCode;
|
||||
use pdfs_core::proton_sdk::{ProtonApiError, ProtonError};
|
||||
let error = pdfs_core::Error::Proton(ProtonError::Api(ProtonApiError {
|
||||
code: ResponseCode::Blocked,
|
||||
http_status: 422,
|
||||
message: "This version of the app is no longer supported, please update to continue using the app".into(),
|
||||
details: None,
|
||||
}));
|
||||
let message = login_error_message(&error);
|
||||
assert!(message.contains("temporarily refusing"), "{message}");
|
||||
assert!(!message.contains("update"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unmapped_error_keeps_its_text() {
|
||||
let error = pdfs_core::Error::Other("something odd".into());
|
||||
|
||||
@@ -140,6 +140,9 @@ pub(crate) struct GalleryState {
|
||||
pub(crate) loading: Cell<bool>,
|
||||
/// Whether the last page came back full, so there may be more to load.
|
||||
pub(crate) has_more: Cell<bool>,
|
||||
/// Whether the account has a photo library. Until the daemon says it has
|
||||
/// none, assume it does, so Upload is not greyed out while the page loads.
|
||||
pub(crate) library: Cell<bool>,
|
||||
/// Run once the page in flight has landed in [`Self::model`] — how the
|
||||
/// lightbox steps past the last loaded photo.
|
||||
pub(crate) page_waiters: RefCell<Vec<Box<dyn FnOnce()>>>,
|
||||
@@ -3341,6 +3344,12 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
items,
|
||||
counts,
|
||||
})) => {
|
||||
// Nothing can go into a library the account does not have; the
|
||||
// SDK refuses the upload before sending it (issue #38).
|
||||
ui.gallery.library.set(available);
|
||||
ui.gallery
|
||||
.upload
|
||||
.set_sensitive(available && *ui.mounted.borrow());
|
||||
if !available {
|
||||
gallery_status(
|
||||
&ui,
|
||||
|
||||
@@ -484,9 +484,7 @@ fn repaint_shared_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
||||
nav.is_empty(),
|
||||
));
|
||||
for (i, (_, name)) in nav.iter().enumerate() {
|
||||
let sep = gtk4::Label::new(Some("›"));
|
||||
sep.add_css_class("dim-label");
|
||||
crumb.append(&sep);
|
||||
crumb.append(&crumb_separator());
|
||||
crumb.append(&shared_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
|
||||
}
|
||||
}
|
||||
@@ -495,12 +493,7 @@ fn repaint_shared_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
||||
/// that goes back up to `depth` folders below the top level.
|
||||
fn shared_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
|
||||
if current {
|
||||
let l = gtk4::Label::builder()
|
||||
.label(label)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
||||
.build();
|
||||
l.add_css_class("heading");
|
||||
return l.upcast();
|
||||
return crumb_current(label);
|
||||
}
|
||||
let button = gtk4::Button::builder().label(label).build();
|
||||
button.add_css_class("flat");
|
||||
|
||||
@@ -565,6 +565,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect();
|
||||
dialog.connect_response(Some("stop"), move |_, _| {
|
||||
let done = done.clone();
|
||||
let stopped = uids.clone();
|
||||
run_requests(
|
||||
&ui_c,
|
||||
uids.iter()
|
||||
@@ -572,6 +573,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
.collect(),
|
||||
gettext_noop("Couldn't stop sharing"),
|
||||
move |ui| {
|
||||
forget_shared_by_me(ui, &stopped);
|
||||
reload_listing(ui);
|
||||
toast(ui, &done);
|
||||
},
|
||||
@@ -580,6 +582,18 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
dialog.present(ui_window(ui).as_ref());
|
||||
}
|
||||
|
||||
/// Take items whose sharing just stopped off the page now, rather than when
|
||||
/// the reload that confirms it comes back: that reload asks after every share
|
||||
/// in turn and takes a while with many of them.
|
||||
fn forget_shared_by_me(ui: &Rc<Ui>, uids: &[String]) {
|
||||
let items = {
|
||||
let mut items = ui.shared_by_me.items.borrow_mut();
|
||||
items.retain(|item| !uids.contains(&item.uid));
|
||||
items.clone()
|
||||
};
|
||||
repaint_shared_by_me(ui, &items);
|
||||
}
|
||||
|
||||
/// Open a shared item: a folder in My Files, a file the way My Files would.
|
||||
fn open_shared_by_me(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
if entry.is_dir {
|
||||
@@ -608,6 +622,7 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
||||
size: 0,
|
||||
modified: item.modified,
|
||||
pinned: false,
|
||||
pinned_by_folder: false,
|
||||
cached: false,
|
||||
uid: item.uid.clone(),
|
||||
path: item.path.clone(),
|
||||
|
||||
@@ -381,23 +381,34 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
||||
.hexpand(true)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
||||
.build();
|
||||
// The app's one menu, on the account row the way Proton's own apps keep it:
|
||||
// app-wide entries first, the account's last. The whole row opens it, and
|
||||
// F10 too, as the primary menu.
|
||||
let app_section = gio::Menu::new();
|
||||
app_section.append(Some(&gettext("Preferences")), Some("win.preferences"));
|
||||
app_section.append(Some(&gettext("Keyboard Shortcuts")), Some("win.shortcuts"));
|
||||
app_section.append(
|
||||
Some(&gettext("About Proton Drive for Linux")),
|
||||
Some("win.about"),
|
||||
);
|
||||
let account_section = gio::Menu::new();
|
||||
account_section.append(Some(&gettext("Sign Out…")), Some("win.sign-out"));
|
||||
let account_menu = gio::Menu::new();
|
||||
account_menu.append(Some(&gettext("Preferences")), Some("win.preferences"));
|
||||
let sign_out = gio::Menu::new();
|
||||
sign_out.append(Some(&gettext("Sign Out…")), Some("win.sign-out"));
|
||||
account_menu.append_section(None, &sign_out);
|
||||
let account_button = gtk4::MenuButton::builder()
|
||||
.icon_name("view-more-symbolic")
|
||||
.tooltip_text(gettext("Account"))
|
||||
account_menu.append_section(None, &app_section);
|
||||
account_menu.append_section(None, &account_section);
|
||||
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
||||
row.append(&avatar);
|
||||
row.append(&account_name);
|
||||
row.append(>k4::Image::from_icon_name("view-more-symbolic"));
|
||||
let account = gtk4::MenuButton::builder()
|
||||
.child(&row)
|
||||
.tooltip_text(gettext("Main menu"))
|
||||
.menu_model(&account_menu)
|
||||
.valign(gtk4::Align::Center)
|
||||
.direction(gtk4::ArrowType::Up)
|
||||
.primary(true)
|
||||
.build();
|
||||
account_button.add_css_class("flat");
|
||||
let account = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
||||
account.add_css_class("flat");
|
||||
account.add_css_class("sidebar-account");
|
||||
account.append(&avatar);
|
||||
account.append(&account_name);
|
||||
account.append(&account_button);
|
||||
|
||||
let footer = gtk4::Box::new(gtk4::Orientation::Vertical, 10);
|
||||
footer.add_css_class("sidebar-footer");
|
||||
@@ -825,6 +836,13 @@ pub(crate) fn wire_retry(ui: &Rc<Ui>) {
|
||||
.connect_clicked(move |_| restart_service_then(&ui_trash, load_trash));
|
||||
}
|
||||
|
||||
/// Show the sign-in page while a session is still stored, because Proton has
|
||||
/// refused it. The sidebar stays, so the user can go back to cached files; a
|
||||
/// login moves on to the files as from any sign-in ([`refresh`]).
|
||||
pub(crate) fn show_login(ui: &Rc<Ui>) {
|
||||
ui.stack.set_visible_child_name("login");
|
||||
}
|
||||
|
||||
/// Repaint the window from the cached login identity, then ask the daemon for
|
||||
/// its status and transfers. Runs at startup, after a login and after a service
|
||||
/// restart; the daemon's event feed keeps both current in between. The identity
|
||||
@@ -940,7 +958,9 @@ fn quota_display(max_space: i64, used_space: i64) -> (f64, String) {
|
||||
pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) {
|
||||
*ui.mounted.borrow_mut() = mounted;
|
||||
sync_mounted_actions(ui, mounted);
|
||||
ui.gallery.upload.set_sensitive(mounted);
|
||||
ui.gallery
|
||||
.upload
|
||||
.set_sensitive(mounted && ui.gallery.library.get());
|
||||
ui.details.details.pin_row.set_sensitive(mounted);
|
||||
ui.details.details.rename_button.set_sensitive(mounted);
|
||||
ui.details.details.trash_button.set_sensitive(mounted);
|
||||
@@ -1210,6 +1230,9 @@ pub(crate) fn paint_status(ui: &Rc<Ui>, status: Response) {
|
||||
failing_error,
|
||||
paused,
|
||||
paused_until,
|
||||
session_expired,
|
||||
tokens_unsaved,
|
||||
version,
|
||||
..
|
||||
} = status
|
||||
else {
|
||||
@@ -1220,7 +1243,13 @@ pub(crate) fn paint_status(ui: &Rc<Ui>, status: Response) {
|
||||
// in it: it is why a file that looks saved is not on the remote
|
||||
// yet, and offline is usually the reason it is still queued.
|
||||
let queued = i18n::pending_summary(pending_uploads, pending_changes);
|
||||
let state = if paused {
|
||||
let state = if session_expired {
|
||||
SyncState::SessionExpired { queued }
|
||||
} else if tokens_unsaved {
|
||||
SyncState::TokensUnsaved
|
||||
} else if pdfs_core::control::daemon_outdated(&version) {
|
||||
SyncState::Outdated { running: version }
|
||||
} else if paused {
|
||||
SyncState::Paused {
|
||||
until: paused_until,
|
||||
queued,
|
||||
@@ -1283,6 +1312,20 @@ pub(crate) fn paint_disconnected(ui: &Rc<Ui>) {
|
||||
|
||||
/// What the sidebar's status strip reports, most urgent first.
|
||||
pub(crate) enum SyncState {
|
||||
/// Proton refused the session: nothing reaches it until the user signs in
|
||||
/// again. Outranks a pause, which it makes moot.
|
||||
SessionExpired {
|
||||
queued: Option<String>,
|
||||
},
|
||||
/// The keyring refuses the session's refreshed tokens. Sync goes on, but
|
||||
/// a reboot before the keyring takes them signs the user out.
|
||||
TokensUnsaved,
|
||||
/// The daemon is an older release than this window: an upgrade replaced
|
||||
/// the files but left the service running, with the old release's bugs,
|
||||
/// until it restarts (#33). `running` is empty when it is too old to say.
|
||||
Outdated {
|
||||
running: String,
|
||||
},
|
||||
/// The user paused syncing; nothing goes up until it resumes. Outranks
|
||||
/// everything else, because it is the reason for everything else.
|
||||
Paused {
|
||||
@@ -1317,7 +1360,44 @@ pub(crate) enum SyncState {
|
||||
fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
||||
let paused = matches!(state, SyncState::Paused { .. });
|
||||
let connected = !matches!(state, SyncState::Disconnected | SyncState::NotResponding);
|
||||
let expired = matches!(state, SyncState::SessionExpired { .. });
|
||||
let outdated = matches!(state, SyncState::Outdated { .. });
|
||||
let (icon, class, title, detail) = match state {
|
||||
SyncState::SessionExpired { queued } => (
|
||||
"dialog-password-symbolic",
|
||||
Some("error"),
|
||||
gettext("Session expired"),
|
||||
Some(match queued {
|
||||
// Translators: {queued} says what is waiting, such as "3 uploads
|
||||
// queued".
|
||||
Some(queued) => {
|
||||
gettext_f("{queued} · sign in again to sync", &[("queued", &queued)])
|
||||
}
|
||||
None => gettext("Sign in again to sync"),
|
||||
}),
|
||||
),
|
||||
SyncState::TokensUnsaved => (
|
||||
"dialog-warning-symbolic",
|
||||
Some("warning"),
|
||||
gettext("Sign-in not saved"),
|
||||
Some(gettext(
|
||||
"Unlock your keyring, or the next reboot signs you out",
|
||||
)),
|
||||
),
|
||||
SyncState::Outdated { running } => (
|
||||
"dialog-warning-symbolic",
|
||||
Some("warning"),
|
||||
gettext("Restart to finish updating"),
|
||||
Some(if running.is_empty() {
|
||||
gettext("An older version of Proton Drive is still running")
|
||||
} else {
|
||||
// Translators: {version} is a release number such as "3.2.0".
|
||||
gettext_f(
|
||||
"Proton Drive {version} is still running",
|
||||
&[("version", &running)],
|
||||
)
|
||||
}),
|
||||
),
|
||||
SyncState::Paused { until, queued } => (
|
||||
"media-playback-pause-symbolic",
|
||||
Some("warning"),
|
||||
@@ -1388,6 +1468,8 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
||||
if let Some(class) = class {
|
||||
image.add_css_class(class);
|
||||
}
|
||||
ui.locations.card.sign_in.set_visible(expired);
|
||||
ui.locations.card.restart.set_visible(outdated);
|
||||
ui.status.status_title.set_label(&title);
|
||||
ui.status.status_detail.set_visible(detail.is_some());
|
||||
ui.status
|
||||
@@ -1456,7 +1538,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(gettext("No files kept offline"))
|
||||
.subtitle(gettext(
|
||||
"Right-click a file and choose “Make available offline”.",
|
||||
"Right-click a file or folder and choose “Available offline”.",
|
||||
))
|
||||
.build();
|
||||
ui.status.pins_group.add(&row);
|
||||
|
||||
@@ -524,6 +524,7 @@ mod tests {
|
||||
size: 0,
|
||||
modified,
|
||||
pinned: false,
|
||||
pinned_by_folder: false,
|
||||
cached: false,
|
||||
uid: name.into(),
|
||||
path: String::new(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The human-verification (CAPTCHA) dialog.
|
||||
//! The human-verification (CAPTCHA) window.
|
||||
//!
|
||||
//! Proton gates logins it does not recognise — a new IP, a VPN exit — behind a
|
||||
//! CAPTCHA. The API answers the login with a challenge instead of a session, and
|
||||
@@ -8,10 +8,26 @@
|
||||
//! The page is hosted in a `WebKitWebView` rather than handed to the system
|
||||
//! browser because it reports completion by posting a message to its host, not
|
||||
//! by redirecting: a browser has nowhere to post it back to.
|
||||
//!
|
||||
//! The webview runs in a child `pdfs-app` started with [`VERIFY_ARG`], not in the
|
||||
//! app itself. WebKit aborts its whole process when its sandbox cannot start, as
|
||||
//! on hardened systems that keep bubblewrap from the journal socket
|
||||
//! (`docs/BUGS.md` B195); in a child that costs the verification, not the app.
|
||||
//! The URL goes to the child on stdin, so it never shows in a process list, and
|
||||
//! the token comes back on stdout.
|
||||
|
||||
use crate::*;
|
||||
use std::io::{BufRead, BufReader, Write as _};
|
||||
use std::process::{ExitStatus, Stdio};
|
||||
use webkit6::prelude::*;
|
||||
|
||||
/// The argument that makes `pdfs-app` the verification window instead of the app.
|
||||
pub(crate) const VERIFY_ARG: &str = "--human-verification";
|
||||
|
||||
/// Marks the token among whatever else lands on the child's stdout: GLib and
|
||||
/// WebKit write debug output there.
|
||||
const TOKEN_PREFIX: &str = "hv-token ";
|
||||
|
||||
/// Bridge the verification page's `postMessage` to a handler this side can read.
|
||||
///
|
||||
/// The page targets its embedding host (`window.parent`), which in a webview is
|
||||
@@ -26,15 +42,100 @@ window.addEventListener('message', function (event) {
|
||||
});
|
||||
"#;
|
||||
|
||||
/// Present the challenge and hand the solved token back through `token_tx`.
|
||||
///
|
||||
/// Closing the dialog without solving drops the sender, which the waiting login
|
||||
/// worker reads as a cancelled sign-in — the same contract as the 2FA prompt.
|
||||
pub(crate) fn prompt_human_verification(
|
||||
ui: &Rc<Ui>,
|
||||
url: &str,
|
||||
token_tx: std::sync::mpsc::Sender<String>,
|
||||
) {
|
||||
/// How the verification window ended.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Verification {
|
||||
Solved(String),
|
||||
/// Closed without solving.
|
||||
Cancelled,
|
||||
/// Never came up, or died: most likely WebKit could not start its sandbox.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Show the challenge in the verification window and wait until it closes.
|
||||
pub(crate) async fn verify_human(url: String) -> Verification {
|
||||
match gio::spawn_blocking(move || run_verification_child(&url)).await {
|
||||
Ok(Ok(verification)) => verification,
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!("cannot run the verification window: {e}");
|
||||
Verification::Failed
|
||||
}
|
||||
Err(_) => Verification::Failed,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_verification_child(url: &str) -> std::io::Result<Verification> {
|
||||
let mut child = Command::new(own_exe())
|
||||
.arg(VERIFY_ARG)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
// A failed write means the child is already gone; its exit status says
|
||||
// how. Dropping `stdin` closes it.
|
||||
let _ = writeln!(stdin, "{url}");
|
||||
}
|
||||
// Stops reading at the token rather than at EOF: WebKit's helper processes
|
||||
// inherit the pipe and may hold it open a moment after the child quits.
|
||||
let token = child
|
||||
.stdout
|
||||
.take()
|
||||
.and_then(|out| find_token(BufReader::new(out)));
|
||||
let status = child.wait()?;
|
||||
Ok(outcome(token, status))
|
||||
}
|
||||
|
||||
/// This very binary, so a development build opens its own window. After an
|
||||
/// upgrade replaced the file, `current_exe` names a deleted path, and the
|
||||
/// installed `pdfs-app` is the next best.
|
||||
fn own_exe() -> PathBuf {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.filter(|path| path.is_file())
|
||||
.unwrap_or_else(|| PathBuf::from("pdfs-app"))
|
||||
}
|
||||
|
||||
fn find_token(out: impl BufRead) -> Option<String> {
|
||||
out.lines()
|
||||
.map_while(Result::ok)
|
||||
.find_map(|line| line.strip_prefix(TOKEN_PREFIX).map(str::to_owned))
|
||||
}
|
||||
|
||||
/// A token wins over the exit status: the user solved the page, even if
|
||||
/// WebKit's teardown crashed after it.
|
||||
fn outcome(token: Option<String>, status: ExitStatus) -> Verification {
|
||||
match token {
|
||||
Some(token) => Verification::Solved(token),
|
||||
None if status.success() => Verification::Cancelled,
|
||||
None => {
|
||||
tracing::error!(%status, "the verification window failed");
|
||||
Verification::Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run as the verification window: read the page's URL from stdin, show it, and
|
||||
/// print the token once the user has solved it. Closing the window exits
|
||||
/// without a token, which the app reads as a cancelled sign-in.
|
||||
pub(crate) fn run_verification_window() -> glib::ExitCode {
|
||||
let mut url = String::new();
|
||||
if std::io::stdin().lock().read_line(&mut url).is_err() || url.trim().is_empty() {
|
||||
tracing::error!("{VERIFY_ARG} expects the verification URL on stdin");
|
||||
return glib::ExitCode::FAILURE;
|
||||
}
|
||||
let url = url.trim().to_owned();
|
||||
|
||||
// Not unique: the app that started it already holds APP_ID.
|
||||
let app = adw::Application::builder()
|
||||
.flags(gio::ApplicationFlags::NON_UNIQUE)
|
||||
.build();
|
||||
app.connect_activate(move |app| build_verification_window(app, &url));
|
||||
// GApplication would refuse VERIFY_ARG as an unknown option.
|
||||
let argv0 = std::env::args().next().unwrap_or_default();
|
||||
app.run_with_args(&[argv0])
|
||||
}
|
||||
|
||||
fn build_verification_window(app: &adw::Application, url: &str) {
|
||||
let content = webkit6::UserContentManager::new();
|
||||
content.add_script(&webkit6::UserScript::new(
|
||||
BRIDGE,
|
||||
@@ -53,24 +154,19 @@ pub(crate) fn prompt_human_verification(
|
||||
.hexpand(true)
|
||||
.build();
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
body.append(&adw::HeaderBar::new());
|
||||
body.append(&webview);
|
||||
let window = adw::ApplicationWindow::builder()
|
||||
.application(app)
|
||||
.title(gettext("Verification"))
|
||||
.content_width(420)
|
||||
.content_height(560)
|
||||
.icon_name(APP_ID)
|
||||
.default_width(420)
|
||||
.default_height(560)
|
||||
.content(&body)
|
||||
.build();
|
||||
|
||||
let header = adw::HeaderBar::new();
|
||||
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
body.append(&header);
|
||||
body.append(&webview);
|
||||
dialog.set_child(Some(&body));
|
||||
|
||||
// Taken on the first success *or* on close, so the token is sent at most
|
||||
// once and a cancelled dialog always drops the sender.
|
||||
let token_tx = Rc::new(RefCell::new(Some(token_tx)));
|
||||
|
||||
let tx = token_tx.clone();
|
||||
let dlg_weak = dialog.downgrade();
|
||||
let app = app.downgrade();
|
||||
content.connect_script_message_received(Some("hv"), move |_, value| {
|
||||
let Some(token) = extract_token(&value.to_str()) else {
|
||||
// Every `postMessage` on the page reaches here, most of them the
|
||||
@@ -78,25 +174,17 @@ pub(crate) fn prompt_human_verification(
|
||||
// is simply not ours.
|
||||
return;
|
||||
};
|
||||
if let Some(tx) = tx.borrow_mut().take() {
|
||||
let _ = tx.send(token);
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
if let Err(e) = writeln!(stdout, "{TOKEN_PREFIX}{token}").and_then(|()| stdout.flush()) {
|
||||
tracing::error!("cannot hand the verification token back: {e}");
|
||||
}
|
||||
if let Some(dlg) = dlg_weak.upgrade() {
|
||||
dlg.close();
|
||||
if let Some(app) = app.upgrade() {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
// Covers the close button, Escape, and the user giving up.
|
||||
dialog.connect_closed(move |_| {
|
||||
token_tx.borrow_mut().take();
|
||||
});
|
||||
|
||||
webview.load_uri(url);
|
||||
|
||||
let parent = ui.login.login_button.root().and_downcast::<gtk4::Window>();
|
||||
// Returns immediately; the login worker stays blocked on the channel until
|
||||
// the handler above sends, or the dialog closes and drops the sender.
|
||||
dialog.present(parent.as_ref());
|
||||
window.present();
|
||||
}
|
||||
|
||||
/// Pull the verification token out of a message posted by the verification page.
|
||||
@@ -133,7 +221,12 @@ fn extract_token(raw: &str) -> Option<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::extract_token;
|
||||
use super::{Verification, extract_token, find_token, outcome};
|
||||
use std::os::unix::process::ExitStatusExt as _;
|
||||
use std::process::ExitStatus;
|
||||
|
||||
/// The wait status of a process killed by SIGABRT.
|
||||
const ABORTED: i32 = 6;
|
||||
|
||||
#[test]
|
||||
fn a_completion_message_yields_its_token() {
|
||||
@@ -163,4 +256,39 @@ mod tests {
|
||||
assert!(extract_token(raw).is_none(), "accepted: {raw}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_token_is_found_among_other_output() {
|
||||
let out = "Gtk-Message: something\nhv-token tok-1\nmore\n";
|
||||
assert_eq!(find_token(out.as_bytes()).as_deref(), Some("tok-1"));
|
||||
assert_eq!(find_token("only chatter\n".as_bytes()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_closed_without_a_token_cancels() {
|
||||
assert_eq!(
|
||||
outcome(None, ExitStatus::from_raw(0)),
|
||||
Verification::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
/// WebKit aborts when its sandbox cannot start (B195). That must read as a
|
||||
/// failure the app explains, not as the user giving up.
|
||||
#[test]
|
||||
fn a_window_that_died_fails() {
|
||||
assert_eq!(
|
||||
outcome(None, ExitStatus::from_raw(ABORTED)),
|
||||
Verification::Failed
|
||||
);
|
||||
let exited_1 = ExitStatus::from_raw(1 << 8);
|
||||
assert_eq!(outcome(None, exited_1), Verification::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_counts_even_if_the_window_died_after_it() {
|
||||
assert_eq!(
|
||||
outcome(Some("tok-1".into()), ExitStatus::from_raw(ABORTED)),
|
||||
Verification::Solved("tok-1".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use crate::*;
|
||||
|
||||
/// The "›" between two breadcrumb segments.
|
||||
pub(crate) fn crumb_separator() -> gtk4::Label {
|
||||
let sep = gtk4::Label::new(Some("›"));
|
||||
sep.add_css_class("dim-label");
|
||||
sep
|
||||
}
|
||||
|
||||
/// The last breadcrumb segment, the folder on screen: a heading rather than a
|
||||
/// button. It carries a flat button's side padding (`.crumb-current`), so the
|
||||
/// "›" before it sits as far from it as from the button before.
|
||||
pub(crate) fn crumb_current(label: &str) -> gtk4::Widget {
|
||||
let l = gtk4::Label::builder()
|
||||
.label(label)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
||||
.build();
|
||||
l.add_css_class("heading");
|
||||
l.add_css_class("crumb-current");
|
||||
l.upcast()
|
||||
}
|
||||
@@ -384,11 +384,20 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
} else {
|
||||
parent.to_string()
|
||||
});
|
||||
// Pinning is per file: the context menu and the selection bar offer it for
|
||||
// files only, so the pane must not offer it for folders either.
|
||||
// Kept through a pinned folder above it, the entry has no pin of its own
|
||||
// to remove, so the switch shows that state without offering to change it.
|
||||
d.pin_row.set_active(entry.pinned);
|
||||
d.pin_row.set_visible(!entry.is_dir);
|
||||
d.pin_row.set_sensitive(*ui.mounted.borrow());
|
||||
d.pin_row.set_subtitle(&if entry.pinned_by_folder {
|
||||
gettext("Kept on this computer because a folder it is in is available offline.")
|
||||
} else if entry.is_dir {
|
||||
gettext(
|
||||
"Keep a copy of everything in this folder on this computer, even without a connection.",
|
||||
)
|
||||
} else {
|
||||
gettext("Keep a copy on this computer, even without a connection.")
|
||||
});
|
||||
d.pin_row
|
||||
.set_sensitive(*ui.mounted.borrow() && !entry.pinned_by_folder);
|
||||
d.open_button.set_label(&if entry.is_dir {
|
||||
gettext("Open folder")
|
||||
} else {
|
||||
|
||||
@@ -132,8 +132,13 @@ impl FileList {
|
||||
// and the grid collapses to a single column. Allowing a mid-word
|
||||
// break is what keeps the two-line-then-ellipsis budget below
|
||||
// enforceable for *every* name rather than only the ones that
|
||||
// happen to have spaces.
|
||||
// happen to have spaces. Pango marks such a break with a hyphen
|
||||
// that is not in the name ("SteamSetup.e-xe"); a file name is
|
||||
// not prose, so break it bare, as Nautilus does.
|
||||
let no_hyphens = gtk4::pango::AttrList::new();
|
||||
no_hyphens.insert(gtk4::pango::AttrInt::new_insert_hyphens(false));
|
||||
let label = gtk4::Label::builder()
|
||||
.attributes(&no_hyphens)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.justify(gtk4::Justification::Center)
|
||||
.max_width_chars(13)
|
||||
|
||||
@@ -61,6 +61,18 @@ impl ActionMenu {
|
||||
self
|
||||
}
|
||||
|
||||
/// A greyed-out check item showing `active`, for a state the user cannot
|
||||
/// change from here.
|
||||
pub(crate) fn locked_toggle(&mut self, label: &str, active: bool) -> &mut Self {
|
||||
let name = self.next_action();
|
||||
let action = gio::SimpleAction::new_stateful(&name, None, &active.to_variant());
|
||||
action.set_enabled(false);
|
||||
self.group.add_action(&action);
|
||||
self.section
|
||||
.append(Some(label), Some(&format!("menu.{name}")));
|
||||
self
|
||||
}
|
||||
|
||||
/// Close the current section; the next item starts a new one below a
|
||||
/// separator. An empty section is skipped, so callers can end a group of
|
||||
/// conditional items unconditionally.
|
||||
@@ -128,20 +140,34 @@ impl ActionMenu {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a menu opened over `anchor` should live in the widget tree: the
|
||||
/// outermost grid or list view around it, or `anchor` itself. CSS descendant
|
||||
/// selectors reach through a popover to its parent, and themes style the
|
||||
/// insides of list items (`gridview > child box { margin: 12px }` in
|
||||
/// Catppuccin); a menu parented to a tile picks those rules up and puts a wide
|
||||
/// margin around every section. Under the view itself it is no item's child.
|
||||
/// Where a menu opened over `anchor` should live in the widget tree.
|
||||
///
|
||||
/// Not inside a list item: CSS descendant selectors reach through a popover to
|
||||
/// its parent, and themes style the insides of list items (`gridview > child
|
||||
/// box { margin: 12px }` in Catppuccin); a menu parented to a tile picks those
|
||||
/// rules up and puts a wide margin around every section. So the search starts
|
||||
/// at the outermost grid or list view around `anchor`.
|
||||
///
|
||||
/// Not on a widget without a layout manager either: a popover is sized and
|
||||
/// placed again when its parent's layout manager allocates it. The list views
|
||||
/// and `ScrolledWindow` have none, so a menu parented there kept the size it was
|
||||
/// first given, often too small, and opened scrolling. The host is the first
|
||||
/// widget from there up that has one.
|
||||
fn popover_host(anchor: >k4::Widget) -> gtk4::Widget {
|
||||
let mut host = anchor.clone();
|
||||
let mut start = anchor.clone();
|
||||
let mut widget = anchor.parent();
|
||||
while let Some(w) = widget {
|
||||
widget = w.parent();
|
||||
if w.is::<gtk4::GridView>() || w.is::<gtk4::ListView>() || w.is::<gtk4::ColumnView>() {
|
||||
host = w;
|
||||
start = w;
|
||||
}
|
||||
}
|
||||
host
|
||||
let mut host = Some(start.clone());
|
||||
while let Some(w) = host {
|
||||
if w.layout_manager().is_some() {
|
||||
return w;
|
||||
}
|
||||
host = w.parent();
|
||||
}
|
||||
start
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub(crate) mod compat;
|
||||
pub(crate) mod crumb;
|
||||
pub(crate) mod details;
|
||||
pub(crate) mod file_list;
|
||||
pub(crate) mod menu;
|
||||
|
||||
@@ -127,6 +127,26 @@ impl Drop for Busy {
|
||||
}
|
||||
}
|
||||
|
||||
/// A section's action, such as Send Invitations. It goes under the section's
|
||||
/// rows, at their right edge, rather than in a row of its own: a row in a boxed
|
||||
/// list reads as a setting, and a button alone in one looks misplaced.
|
||||
fn group_button(label: &str) -> gtk4::Button {
|
||||
let button = gtk4::Button::builder()
|
||||
.label(label)
|
||||
.halign(gtk4::Align::End)
|
||||
.margin_top(12)
|
||||
.build();
|
||||
button.add_css_class("suggested-action");
|
||||
button
|
||||
}
|
||||
|
||||
/// A section's only row while the share is being read.
|
||||
fn loading_row() -> adw::ActionRow {
|
||||
let row = adw::ActionRow::builder().title(gettext("Loading…")).build();
|
||||
row.add_prefix(&spinner());
|
||||
row
|
||||
}
|
||||
|
||||
/// How an open Share dialog addresses its node.
|
||||
///
|
||||
/// A node the browser is showing has a mountpoint-relative path. A node reached
|
||||
@@ -287,26 +307,17 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
.selected(0)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
let role_wrap = adw::ActionRow::builder().title(gettext("Role")).build();
|
||||
role_wrap.add_suffix(&role_drop);
|
||||
// The role goes with the addresses it applies to, at the end of their row.
|
||||
role_drop.set_tooltip_text(Some(&gettext("Role")));
|
||||
email_row.add_suffix(&role_drop);
|
||||
let message_row = adw::EntryRow::builder()
|
||||
.title(gettext("Message (optional)"))
|
||||
.build();
|
||||
let invite_btn = gtk4::Button::builder()
|
||||
.label(gettext("Send Invitations"))
|
||||
.halign(gtk4::Align::End)
|
||||
.margin_top(6)
|
||||
.build();
|
||||
invite_btn.add_css_class("suggested-action");
|
||||
let invite_btn = group_button(&gettext("Send Invitations"));
|
||||
invite_btn.set_sensitive(false);
|
||||
let invite_wrap = adw::PreferencesRow::builder()
|
||||
.activatable(false)
|
||||
.child(&invite_btn)
|
||||
.build();
|
||||
invite_group.add(&email_row);
|
||||
invite_group.add(&role_wrap);
|
||||
invite_group.add(&message_row);
|
||||
invite_group.add(&invite_wrap);
|
||||
invite_group.add(&invite_btn);
|
||||
|
||||
let people = adw::PreferencesGroup::builder()
|
||||
.title(gettext("People with access"))
|
||||
@@ -315,21 +326,18 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
.title(gettext("Public link"))
|
||||
.build();
|
||||
|
||||
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
content.set_margin_top(12);
|
||||
content.set_margin_bottom(12);
|
||||
content.set_margin_start(12);
|
||||
content.set_margin_end(12);
|
||||
content.append(&invite_group);
|
||||
content.append(&people);
|
||||
content.append(&link_group);
|
||||
let clamp = adw::Clamp::builder().child(&content).build();
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
toolbar.set_content(Some(&scroll));
|
||||
// Until the share is read, each section says so rather than standing
|
||||
// empty under its title.
|
||||
let people_loading = loading_row();
|
||||
people.add(&people_loading);
|
||||
let link_loading = loading_row();
|
||||
link_group.add(&link_loading);
|
||||
|
||||
let content = adw::PreferencesPage::new();
|
||||
content.add(&invite_group);
|
||||
content.add(&people);
|
||||
content.add(&link_group);
|
||||
toolbar.set_content(Some(&content));
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title(pgettext("verb", "Share"))
|
||||
@@ -343,8 +351,8 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
target,
|
||||
people,
|
||||
link_group,
|
||||
people_rows: RefCell::new(Vec::new()),
|
||||
link_rows: RefCell::new(Vec::new()),
|
||||
people_rows: RefCell::new(vec![people_loading.upcast()]),
|
||||
link_rows: RefCell::new(vec![link_loading.upcast()]),
|
||||
});
|
||||
|
||||
// Check the addresses as they are typed: a malformed one marks the row as
|
||||
@@ -430,17 +438,35 @@ pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
|
||||
repaint_share_link(&state, link.as_ref());
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) => {
|
||||
share_dialog_unread(&state);
|
||||
toast_error(&state.ui, &gettext("Couldn't load sharing"), &message)
|
||||
}
|
||||
_ => toast_error(
|
||||
&state.ui,
|
||||
&gettext("Couldn't load sharing"),
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
_ => {
|
||||
share_dialog_unread(&state);
|
||||
toast_error(
|
||||
&state.ui,
|
||||
&gettext("Couldn't load sharing"),
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The share couldn't be read: what the sections showed (rows still loading,
|
||||
/// or ones that may now be stale) gives way to one line that says so.
|
||||
fn share_dialog_unread(state: &Rc<ShareDialog>) {
|
||||
for row in state.people_rows.borrow_mut().drain(..) {
|
||||
state.people.remove(&row);
|
||||
}
|
||||
for row in state.link_rows.borrow_mut().drain(..) {
|
||||
state.link_group.remove(&row);
|
||||
}
|
||||
let row = dim_row(&gettext("Couldn't load sharing"));
|
||||
state.people.add(&row);
|
||||
state.people_rows.borrow_mut().push(row.upcast());
|
||||
}
|
||||
|
||||
/// Rebuild the "People with access" rows from a fresh share listing.
|
||||
pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntry]) {
|
||||
for row in state.people_rows.borrow_mut().drain(..) {
|
||||
@@ -455,15 +481,18 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
||||
return;
|
||||
}
|
||||
for entry in entries {
|
||||
// A member is the plain case and needs no word; an invitation says
|
||||
// it is still waiting on the other side.
|
||||
let subtitle = match entry.kind {
|
||||
ShareEntryKind::Member => gettext("Member"),
|
||||
ShareEntryKind::ProtonInvite => gettext("Invited (pending)"),
|
||||
ShareEntryKind::ExternalInvite => gettext("Invited (external, pending)"),
|
||||
ShareEntryKind::Member => String::new(),
|
||||
ShareEntryKind::ProtonInvite => gettext("Invitation pending"),
|
||||
ShareEntryKind::ExternalInvite => gettext("Invitation pending, no Proton account yet"),
|
||||
};
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&entry.email)
|
||||
.subtitle(&subtitle)
|
||||
.build();
|
||||
row.add_prefix(&adw::Avatar::new(32, Some(&entry.email), true));
|
||||
|
||||
// External invites can't have their role changed; show it read-only.
|
||||
// Members and Proton invites get a role dropdown.
|
||||
@@ -653,16 +682,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
.build();
|
||||
let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build();
|
||||
expiry_row.add_suffix(&expiry_drop);
|
||||
let create = gtk4::Button::builder()
|
||||
.label(gettext("Create Public Link"))
|
||||
.halign(gtk4::Align::End)
|
||||
.margin_top(6)
|
||||
.build();
|
||||
create.add_css_class("suggested-action");
|
||||
let create_wrap = adw::PreferencesRow::builder()
|
||||
.activatable(false)
|
||||
.child(&create)
|
||||
.build();
|
||||
let create = group_button(&gettext("Create Public Link"));
|
||||
|
||||
// Enter in the password field creates the link.
|
||||
let btn = create.clone();
|
||||
@@ -689,11 +709,11 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
state.link_group.add(&role_row);
|
||||
state.link_group.add(&pw_row);
|
||||
state.link_group.add(&expiry_row);
|
||||
state.link_group.add(&create_wrap);
|
||||
state.link_group.add(&create);
|
||||
rows.push(role_row.upcast());
|
||||
rows.push(pw_row.upcast());
|
||||
rows.push(expiry_row.upcast());
|
||||
rows.push(create_wrap.upcast());
|
||||
rows.push(create.upcast());
|
||||
}
|
||||
}
|
||||
*state.link_rows.borrow_mut() = rows;
|
||||
|
||||
@@ -77,13 +77,16 @@ pub(crate) fn icon_for(name: &str, is_dir: bool) -> gio::Icon {
|
||||
}
|
||||
|
||||
/// The sync badge of a Drive file, as in the app's browser: pinned, cached,
|
||||
/// or online only. Folders and local files have none.
|
||||
/// or online only. A folder has one only when kept offline; local files have
|
||||
/// none.
|
||||
pub(crate) fn badge_for(hit: &Hit) -> Option<(&'static str, &'static str)> {
|
||||
let Hit::Drive(drive) = hit else {
|
||||
return None;
|
||||
};
|
||||
if drive.is_dir {
|
||||
return None;
|
||||
return drive
|
||||
.pinned
|
||||
.then_some(("pdfs-offline-symbolic", "badge-pinned"));
|
||||
}
|
||||
Some(if drive.pinned {
|
||||
("pdfs-offline-symbolic", "badge-pinned")
|
||||
|
||||
+166
-24
@@ -22,7 +22,8 @@ use ksni::{MenuItem, Status, ToolTip, Tray};
|
||||
use pdfs_core::auth;
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
Event, Feed, JobItem, Request, Response, TransferDirection, TransferItem, follow, send,
|
||||
Event, Feed, JobItem, Request, Response, TransferDirection, TransferItem, daemon_outdated,
|
||||
follow, send,
|
||||
};
|
||||
use pdfs_core::service;
|
||||
|
||||
@@ -45,6 +46,8 @@ enum Phase {
|
||||
Offline,
|
||||
/// Changes keep failing; the user has to look.
|
||||
Attention,
|
||||
/// Mounted, but Proton refused the session: only signing in again helps.
|
||||
SessionExpired,
|
||||
/// Signed in, but no mount daemon is running.
|
||||
Disconnected,
|
||||
SignedOut,
|
||||
@@ -60,6 +63,7 @@ impl Phase {
|
||||
Phase::Paused => "media-playback-pause-symbolic",
|
||||
Phase::Offline => "network-offline-symbolic",
|
||||
Phase::Attention => "dialog-warning-symbolic",
|
||||
Phase::SessionExpired => "dialog-password-symbolic",
|
||||
Phase::Disconnected | Phase::SignedOut => "network-offline-symbolic",
|
||||
}
|
||||
}
|
||||
@@ -83,13 +87,17 @@ struct DriveState {
|
||||
paused: bool,
|
||||
/// Queued changes that keep failing, for the "N issues" item.
|
||||
failing: u64,
|
||||
/// Whether the daemon is an older release than the tray, left running by
|
||||
/// an upgrade; offers the restart that finishes it.
|
||||
outdated: bool,
|
||||
}
|
||||
|
||||
/// Summarise a work snapshot into one menu line, or empty when idle. A single
|
||||
/// transfer names the file and its percentage; several collapse to counts so the
|
||||
/// menu stays one line regardless of queue depth. With nothing moving bytes, a
|
||||
/// running job speaks for itself — a scan or an index rebuild is still "busy",
|
||||
/// and the tray saying nothing there reads as "finished".
|
||||
/// running job speaks for itself — a scan or an index rebuild is still work
|
||||
/// going on, and the menu saying nothing there reads as "finished". The icon is
|
||||
/// [`busy`]'s call, not this line's.
|
||||
fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
|
||||
match items {
|
||||
[] => match jobs.first() {
|
||||
@@ -143,12 +151,12 @@ struct DriveTray {
|
||||
fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||
match send(socket, &Request::Status) {
|
||||
Ok(status @ Response::Status { .. }) => {
|
||||
// Same daemon is up, so a cheap follow-up request gives the sync line.
|
||||
let sync = match send(socket, &Request::GetQueueStatus) {
|
||||
Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs),
|
||||
_ => String::new(),
|
||||
// Same daemon is up, so a cheap follow-up request says what moves.
|
||||
let (items, jobs) = match send(socket, &Request::GetQueueStatus) {
|
||||
Ok(Response::Transfers { items, jobs }) => (items, jobs),
|
||||
_ => (Vec::new(), Vec::new()),
|
||||
};
|
||||
status_state(&status, sync, default_mountpoint)
|
||||
status_state(&status, &items, &jobs, default_mountpoint)
|
||||
}
|
||||
// Socket answered but with something unexpected — treat as up but odd.
|
||||
Ok(_) => unexpected_reply(default_mountpoint),
|
||||
@@ -162,6 +170,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||
sync: String::new(),
|
||||
paused: false,
|
||||
failing: 0,
|
||||
outdated: false,
|
||||
},
|
||||
// No daemon: describe login state instead so the menu is still useful.
|
||||
Err(_) => {
|
||||
@@ -198,6 +207,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||
sync: String::new(),
|
||||
paused: false,
|
||||
failing: 0,
|
||||
outdated: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,12 +224,18 @@ fn unexpected_reply(default_mountpoint: &Path) -> DriveState {
|
||||
sync: String::new(),
|
||||
paused: false,
|
||||
failing: 0,
|
||||
outdated: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The state a running daemon's [`Response::Status`] describes, with `sync`
|
||||
/// saying what it is moving right now.
|
||||
fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> DriveState {
|
||||
/// The state a running daemon's [`Response::Status`] describes, with `items`
|
||||
/// and `jobs` saying what it is working on right now.
|
||||
fn status_state(
|
||||
status: &Response,
|
||||
items: &[TransferItem],
|
||||
jobs: &[JobItem],
|
||||
default_mountpoint: &Path,
|
||||
) -> DriveState {
|
||||
let Response::Status {
|
||||
mountpoint,
|
||||
pinned,
|
||||
@@ -228,6 +244,9 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
|
||||
pending_changes,
|
||||
failing_ops,
|
||||
paused,
|
||||
session_expired,
|
||||
tokens_unsaved,
|
||||
version,
|
||||
..
|
||||
} = status
|
||||
else {
|
||||
@@ -235,14 +254,29 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
|
||||
};
|
||||
let (pinned, online, failing_ops, paused) = (*pinned, *online, *failing_ops, *paused);
|
||||
let queued = i18n::pending_summary(*pending_uploads, *pending_changes);
|
||||
let phase = phase_of(
|
||||
paused,
|
||||
failing_ops,
|
||||
online,
|
||||
queued.is_some() || !sync.is_empty(),
|
||||
);
|
||||
let outdated = daemon_outdated(version);
|
||||
let phase = if *session_expired {
|
||||
Phase::SessionExpired
|
||||
} else if *tokens_unsaved || outdated {
|
||||
Phase::Attention
|
||||
} else {
|
||||
phase_of(
|
||||
paused,
|
||||
failing_ops,
|
||||
online,
|
||||
queued.is_some() || busy(items, jobs),
|
||||
)
|
||||
};
|
||||
DriveState {
|
||||
line: match (online, queued) {
|
||||
// Outranks everything: it is why nothing else moves, and the one
|
||||
// thing that fixes it is the user's to do.
|
||||
_ if *session_expired => gettext("Session expired — sign in again"),
|
||||
// Sync goes on meanwhile, but a reboot ends it until the next login.
|
||||
_ if *tokens_unsaved => gettext("Sign-in not saved — unlock your keyring"),
|
||||
// An upgrade replaced the files but not the running service, which
|
||||
// keeps the old release's bugs until it restarts.
|
||||
_ if outdated => gettext("Update not finished — restart Proton Drive"),
|
||||
_ if paused => gettext("Sync paused"),
|
||||
_ if failing_ops > 0 => ngettext_f(
|
||||
"{n} change needs attention",
|
||||
@@ -278,11 +312,20 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
|
||||
mounted: true,
|
||||
paused,
|
||||
mountpoint: PathBuf::from(mountpoint),
|
||||
sync,
|
||||
sync: sync_line(items, jobs),
|
||||
failing: failing_ops,
|
||||
outdated,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the daemon is changing something: bytes moving, or a job the user
|
||||
/// is waiting on. A mirror folder's routine scan and the like are background
|
||||
/// jobs; they still get the menu line, but the icon spinning every two minutes
|
||||
/// for a scan that finds nothing reads as constant syncing.
|
||||
fn busy(items: &[TransferItem], jobs: &[JobItem]) -> bool {
|
||||
!items.is_empty() || jobs.iter().any(|j| !j.background)
|
||||
}
|
||||
|
||||
/// The phase a running mount is in. A pause is the user's own doing and says
|
||||
/// so first; failures outrank everything else the daemon reports.
|
||||
fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase {
|
||||
@@ -351,7 +394,7 @@ impl Tray for DriveTray {
|
||||
|
||||
fn status(&self) -> Status {
|
||||
match self.state.phase {
|
||||
Phase::Attention => Status::NeedsAttention,
|
||||
Phase::Attention | Phase::SessionExpired => Status::NeedsAttention,
|
||||
_ => Status::Active,
|
||||
}
|
||||
}
|
||||
@@ -409,9 +452,19 @@ impl Tray for DriveTray {
|
||||
// Nothing to mount without a session; the app's sign-in page is the
|
||||
// way in.
|
||||
Phase::SignedOut => items.push(item(&gettext("Sign In…"), |_| open_manager(&[]))),
|
||||
Phase::SessionExpired => items.push(item(&gettext("Sign In Again…"), |_| {
|
||||
open_manager(&["--page", "login"])
|
||||
})),
|
||||
_ => items.push(item(&gettext("Open Proton Drive"), |_| open_manager(&[]))),
|
||||
}
|
||||
|
||||
if self.state.outdated {
|
||||
items.push(item(&gettext("Restart Proton Drive"), |this| {
|
||||
service::restart();
|
||||
this.state.line = gettext("Connecting…");
|
||||
}));
|
||||
}
|
||||
|
||||
if self.state.mounted {
|
||||
items.push(
|
||||
StandardItem {
|
||||
@@ -485,6 +538,10 @@ fn main() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// The socket lives in the state dir, which nothing has made yet when the app
|
||||
// starts the tray on a fresh install (B194).
|
||||
dirs.ensure().context("create the app's directories")?;
|
||||
|
||||
// Ensure only one instance of the tray runs.
|
||||
let tray_sock = dirs.tray_socket();
|
||||
if std::os::unix::net::UnixStream::connect(&tray_sock).is_ok() {
|
||||
@@ -543,11 +600,7 @@ fn main() -> Result<()> {
|
||||
heard.take(feed);
|
||||
}
|
||||
let st = match &heard.status {
|
||||
Some(status) => status_state(
|
||||
status,
|
||||
sync_line(&heard.items, &heard.jobs),
|
||||
&default_mountpoint,
|
||||
),
|
||||
Some(status) => status_state(status, &heard.items, &heard.jobs, &default_mountpoint),
|
||||
// Lost the daemon: ask once, which describes the sign-in instead.
|
||||
None => poll_state(&socket, &default_mountpoint),
|
||||
};
|
||||
@@ -592,6 +645,95 @@ mod tests {
|
||||
assert_eq!(phase_of(false, 0, true, false), Phase::Synced);
|
||||
}
|
||||
|
||||
fn status(extra: &str) -> Response {
|
||||
status_from(env!("CARGO_PKG_VERSION"), extra)
|
||||
}
|
||||
|
||||
fn status_from(version: &str, extra: &str) -> Response {
|
||||
serde_json::from_str(&format!(
|
||||
r#"{{"Status":{{"username":"u","mountpoint":"/m","pinned":0,"used":0,"budget":0,
|
||||
"pins":[],"online":false,"pending_uploads":3,"failing_ops":2,"paused":true,
|
||||
"version":"{version}"{extra}}}}}"#
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Issue #35: a refused session read as "Offline — 3 uploads queued" for
|
||||
/// good, and nothing said that signing in again was the way out.
|
||||
#[test]
|
||||
fn a_refused_session_outranks_everything_else() {
|
||||
let state = status_state(
|
||||
&status(r#","session_expired":true"#),
|
||||
&[],
|
||||
&[],
|
||||
Path::new("/m"),
|
||||
);
|
||||
assert_eq!(state.phase, Phase::SessionExpired);
|
||||
assert_eq!(state.line, "Session expired — sign in again");
|
||||
}
|
||||
|
||||
/// B61: the keyring refusing the refreshed tokens was only in the log.
|
||||
#[test]
|
||||
fn a_sign_in_the_keyring_refused_needs_attention() {
|
||||
let state = status_state(
|
||||
&status(r#","tokens_unsaved":true"#),
|
||||
&[],
|
||||
&[],
|
||||
Path::new("/m"),
|
||||
);
|
||||
assert_eq!(state.phase, Phase::Attention);
|
||||
assert_eq!(state.line, "Sign-in not saved — unlock your keyring");
|
||||
}
|
||||
|
||||
/// #33: a daemon an upgrade left running on the old release is named,
|
||||
/// above a pause, and the menu offers the restart that finishes it.
|
||||
#[test]
|
||||
fn a_daemon_older_than_the_tray_needs_a_restart() {
|
||||
let state = status_state(&status_from("0.1.0", ""), &[], &[], Path::new("/m"));
|
||||
assert_eq!(state.phase, Phase::Attention);
|
||||
assert_eq!(state.line, "Update not finished — restart Proton Drive");
|
||||
assert!(state.outdated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_daemon_that_does_not_say_has_a_live_session() {
|
||||
let state = status_state(&status(""), &[], &[], Path::new("/m"));
|
||||
assert_eq!(state.phase, Phase::Paused);
|
||||
}
|
||||
|
||||
fn job(title: &str, background: bool) -> JobItem {
|
||||
JobItem {
|
||||
title: title.to_string(),
|
||||
detail: String::new(),
|
||||
done: 0,
|
||||
total: 0,
|
||||
background,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_routine_scan_is_not_busy_but_a_sync_is() {
|
||||
assert!(!busy(&[], &[]));
|
||||
assert!(!busy(&[], &[job("Checking Documents", true)]));
|
||||
assert!(busy(
|
||||
&[],
|
||||
&[
|
||||
job("Checking Documents", true),
|
||||
job("Syncing Photos", false)
|
||||
]
|
||||
));
|
||||
assert!(busy(&[], &[job("Uploading files", false)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_job_from_an_older_daemon_counts_as_busy() {
|
||||
let job: JobItem = serde_json::from_str(
|
||||
r#"{"title":"Checking Documents","detail":"","done":0,"total":0}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!job.background);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_issues_item_counts_in_the_singular_and_plural() {
|
||||
assert_eq!(issues_label(1), "1 issue — View");
|
||||
|
||||
@@ -28,6 +28,7 @@ const STOCK: &[&str] = &[
|
||||
"checkbox-symbolic",
|
||||
"computer-symbolic",
|
||||
"dialog-error-symbolic",
|
||||
"dialog-password-symbolic",
|
||||
"dialog-warning-symbolic",
|
||||
"document-edit-symbolic",
|
||||
"document-open-recent-symbolic",
|
||||
|
||||
+22
-7
@@ -310,8 +310,10 @@ its changes go through the queue like My files.
|
||||
A filesystem watcher (`notify`) and a remote poll every 120 s feed a debounced pass (2 s, at most
|
||||
30 s). Passes are serialized per daemon. A pass:
|
||||
|
||||
1. **Walks the local tree.** Any `readdir`, metadata or permission failure marks the scan
|
||||
incomplete, and an incomplete scan cannot delete anything.
|
||||
1. **Walks the local tree.** A path the user may not read, an entry that vanishes mid-scan and a
|
||||
name that is not UTF-8 are left out, and the pass drops everything under them, as it drops
|
||||
ignored paths, so a left-out path is never read as deleted. An unreadable root and any other
|
||||
I/O error fail the pass.
|
||||
2. **Walks the remote tree.**
|
||||
3. **Loads the baseline**, the `sync_entry` rows: both sides as of the last successful pass.
|
||||
4. **Classifies each path** by comparing local, remote and baseline, with `(mtime, size)` as the
|
||||
@@ -377,6 +379,12 @@ Restore lists the device's folders, proposes a local path from the profile, and
|
||||
adds a synced folder bound to the remote uid. The download is an ordinary pass against an empty
|
||||
baseline. The user side is in [RECOVERY.md](RECOVERY.md).
|
||||
|
||||
`Core::migrate_device` moves another device's top-level folders under this machine's device root.
|
||||
Device roots are separate shares on the main volume, so each folder is one server-side move that
|
||||
keeps its uid. A name already taken here gets the other device's name as a suffix. A synced folder
|
||||
that already tracked one of them keeps its `remote_uid` and only has its `remote_share_id`
|
||||
re-pointed. The moved folders are then offered by the ordinary restore.
|
||||
|
||||
---
|
||||
|
||||
## 8. Control protocol
|
||||
@@ -542,7 +550,7 @@ sequenceDiagram
|
||||
actor User
|
||||
participant UI as pdfs-app
|
||||
participant Auth as pdfs-core auth
|
||||
participant Web as WebKitGTK dialog
|
||||
participant Web as pdfs-app --human-verification
|
||||
participant API as Proton API
|
||||
|
||||
User->>UI: email and password
|
||||
@@ -550,9 +558,9 @@ sequenceDiagram
|
||||
Auth->>API: SRP handshake
|
||||
API-->>Auth: 9001, challenge URL
|
||||
Auth-->>UI: HumanVerificationRequired
|
||||
UI->>Web: load challenge
|
||||
UI->>Web: challenge URL (stdin)
|
||||
User->>Web: solve it
|
||||
Web-->>UI: token (postMessage)
|
||||
Web-->>UI: token (stdout)
|
||||
UI->>Auth: login_verified(token)
|
||||
Auth->>API: new SRP handshake with the token
|
||||
API-->>Auth: session
|
||||
@@ -561,8 +569,15 @@ sequenceDiagram
|
||||
- The challenge used up the first SRP handshake, so the retry starts a new one with the token
|
||||
attached.
|
||||
- The page may post the token as an object or as a JSON string; both are accepted.
|
||||
- The CLI has no web view. It fails with a message to sign in once with `pdfs-app`; the stored
|
||||
session then works for the CLI and the service.
|
||||
- The page runs in a WebKitGTK view inside a child `pdfs-app`. WebKit aborts its process when its
|
||||
sandbox cannot start, as on hardened systems such as secureblue (B195). In the child, that ends
|
||||
the web view, not the app.
|
||||
- When the child fails, the app offers the page in the user's own browser instead, as Proton Mail
|
||||
Bridge does. A browser tab cannot post the token back, so the user says when they are done, and
|
||||
the retry carries the challenge token itself, which the solved page made good. Its type header
|
||||
lists every offered method (`auth::browser_verified`).
|
||||
- The CLI has no web view, so it always takes the browser path: it prints the page's URL and waits
|
||||
for Enter.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+546
-3
@@ -47,7 +47,7 @@ original wording.
|
||||
it in the index in the same change.
|
||||
|
||||
```markdown
|
||||
### B194 — What the user sees, in one line
|
||||
### B196 — What the user sees, in one line
|
||||
|
||||
**Status:** Open
|
||||
**Found:** 2026-10-06, how it was found
|
||||
@@ -73,7 +73,6 @@ original wording.
|
||||
| [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 |
|
||||
| [B66](#b66--local-only-pending-data-is-not-protected-at-shutdown-high-21) | Local-only pending data is not protected at shutdown (HIGH-21) | No shutdown protection and no `pdfs sync flush` |
|
||||
| [B65](#b65--release-artifacts-lack-supply-chain-verification-med-11) | Release artifacts lack supply-chain verification (MED-11) | No SBOM, provenance or checksums |
|
||||
| [B61](#b61--rotated-single-use-credentials-can-be-lost-high-18) | Rotated single-use credentials can be lost (HIGH-18) | Keyring write failures are only logged |
|
||||
| [B15](#b15--an-empty-but-listed-folder-and-duplicated-folder-uids) | An empty-but-`listed` folder, and duplicated folder uids | Cause not attributed |
|
||||
| [B10](#b10--glib-critical-when-the-launcher-closes-over-an-in-flight-open) | GLib critical when the launcher closes over an in-flight open | |
|
||||
|
||||
@@ -81,6 +80,7 @@ original wording.
|
||||
|
||||
| Entry | Title | What is left |
|
||||
|---|---|---|
|
||||
| [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 |
|
||||
| [B71](#b71--conflict-sweep-is-not-production-ready-auto-trash-loop-crit-13) | Conflict sweep is not production-ready (auto-trash loop, CRIT-13) | Items 5 to 10 of the entry |
|
||||
@@ -100,6 +100,21 @@ a live account since.
|
||||
|
||||
| Entry | Title |
|
||||
|---|---|
|
||||
| [B210](#b210--after-a-password-reset-the-mount-retries-missing-key-forever) | After a password reset, the mount retries "missing key" forever |
|
||||
| [B209](#b209--a-blocked-sign-in-says-the-app-version-is-no-longer-supported) | A blocked sign-in says the app version is no longer supported |
|
||||
| [B208](#b208--a-file-landing-while-its-folder-is-pinned-is-kept-but-not-available-offline) | A file landing while its folder is pinned is kept but not available offline |
|
||||
| [B207](#b207--a-move-between-locations-goes-ahead-under-a-file-opened-just-after-its-create-landed) | A move between locations goes ahead under a file opened just after its create landed |
|
||||
| [B206](#b206--an-upgrade-leaves-the-old-daemon-running-and-nothing-says-so) | An upgrade leaves the old daemon running, and nothing says so |
|
||||
| [B205](#b205--a-photo-upload-the-sdk-refuses-says-youre-offline) | A photo upload the SDK refuses says "You're offline" |
|
||||
| [B204](#b204--a-write-over-a-partial-edit-whose-base-was-never-read-fails-with-eio) | A write over a partial edit whose base was never read fails with `EIO` |
|
||||
| [B203](#b203--pdfs-unlock-leaves-a-running-daemon-a-spent-refresh-token) | `pdfs unlock` leaves a running daemon a spent refresh token |
|
||||
| [B202](#b202--a-refused-session-shows-as-offline-forever-and-nothing-says-to-sign-in-again) | A refused session shows as offline forever, and nothing says to sign in again |
|
||||
| [B201](#b201--after-signing-in-again-the-old-daemon-puts-its-spent-tokens-back) | After signing in again, the old daemon puts its spent tokens back |
|
||||
| [B200](#b200--offline-with-changes-queued-the-daemon-keeps-several-cores-busy) | Offline with changes queued, the daemon keeps several cores busy |
|
||||
| [B198](#b198--switching-to-online-only-deletes-local-files-that-never-reached-drive) | Switching to online-only deletes local files that never reached Drive |
|
||||
| [B197](#b197--switching-to-online-only-trashes-synced-files-that-became-ignored) | Switching to online-only trashes synced files that became ignored |
|
||||
| [B196](#b196--one-unreadable-folder-stops-the-whole-synced-folder-from-syncing) | One unreadable folder stops the whole synced folder from syncing |
|
||||
| [B195](#b195--the-app-crashes-when-proton-asks-for-a-captcha-on-secureblue) | The app crashes when Proton asks for a CAPTCHA on secureblue |
|
||||
| [B193](#b193--deleting-a-file-whose-create-failed-can-trash-a-file-of-the-users-on-drive) | Deleting a file whose create failed can trash a file of the user's on Drive |
|
||||
| [B191](#b191--a-file-deleted-after-its-create-failed-and-was-written-again-can-stay-on-drive) | A file deleted after its create failed and was written again can stay on Drive |
|
||||
| [B190](#b190--a-file-saved-just-before-the-daemon-stops-can-keep-its-old-content-on-drive) | A file saved just before the daemon stops can keep its old content on Drive |
|
||||
@@ -184,6 +199,7 @@ a live account since.
|
||||
| [B81](#b81--prompt-labels-every-drive-hit-my-files-including-device-folders) | Prompt labels every Drive hit "My files", including device folders |
|
||||
| [B80](#b80--29-of-the-account-is-absent-from-the-search-index) | 29% of the account is absent from the search index |
|
||||
| [B62](#b62--debian-artifact-omits-required-service-and-autostart-units-high-19) | Debian artifact omits required service and autostart units (HIGH-19) |
|
||||
| [B61](#b61--rotated-single-use-credentials-can-be-lost-high-18) | Rotated single-use credentials can be lost (HIGH-18) |
|
||||
| [B60](#b60--config-writes-are-non-atomic-and-parse-errors-are-overwritten-high-17) | Config writes are non-atomic and parse errors are overwritten (HIGH-17) |
|
||||
| [B59](#b59--private-state-permission-enforcement-fails-open-high-16) | Private state permission enforcement fails open (HIGH-16) |
|
||||
| [B58](#b58--control-socket-has-unbounded-frames-and-connections-high-15) | Control socket has unbounded frames and connections (HIGH-15) |
|
||||
@@ -214,6 +230,7 @@ a live account since.
|
||||
|
||||
| Entry | Title |
|
||||
|---|---|
|
||||
| [B194](#b194--on-a-fresh-install-the-tray-exits-and-the-takeout-page-cannot-remember-its-archives) | On a fresh install the tray exits and the Takeout page cannot remember its archives |
|
||||
| [B192](#b192--a-new-file-closed-empty-reaches-drive-only-half-a-minute-later) | A new file closed empty reaches Drive only half a minute later |
|
||||
| [B181](#b181--the-app-shows-a-queued-edit-kept-as-a-conflict-copy-in-english) | The app shows a queued edit kept as a conflict copy in English |
|
||||
| [B180](#b180--a-stop-waits-10-s-for-a-drain-worker-that-slept-through-it) | A stop waits 10 s for a drain worker that slept through it |
|
||||
@@ -296,6 +313,508 @@ a live account since.
|
||||
|
||||
Newest first.
|
||||
|
||||
### B210 — After a password reset, the mount retries "missing key" forever
|
||||
|
||||
**Status:** Fixed, not verified live
|
||||
**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
|
||||
create `control.sock`.
|
||||
**Where:** `ProtonDriveClient::ensure_my_files` in proton-drive-rs; `fetch_or_recall_root` in
|
||||
`crates/pdfs-fuse/src/mount.rs`
|
||||
|
||||
**Repro:** Reset the account password through Proton's recovery flow, which replaces the account
|
||||
keys, then sign in on a client without a cache.
|
||||
|
||||
**Cause.** A password reset gives the account new keys and locks the Drive volume whose share was
|
||||
encrypted to the old ones. `v2/shares/my-files` still returns that share, so none of the unlocked
|
||||
address keys can open its passphrase. The SDK only created a new volume when the share did not
|
||||
exist, so every mount attempt failed the same way. The web client checks for an active regular
|
||||
volume and creates a new one when there is none.
|
||||
|
||||
**Fix.** proton-drive-rs 0.9.1 handles a share key that fails to decrypt the way the web client
|
||||
does. When `GET volumes` shows no active regular volume, it creates a new My Files volume and uses
|
||||
that. The locked volume is left alone and can still be restored from the web with the previous
|
||||
password.
|
||||
|
||||
**Test:** `client::tests::a_locked_main_volume_is_not_an_active_regular_one`,
|
||||
`client::tests::an_active_regular_volume_is_found_with_or_without_a_type` (proton-drive-rs).
|
||||
|
||||
---
|
||||
|
||||
### B209 — A blocked sign-in says the app version is no longer supported
|
||||
|
||||
**Status:** Fixed, not verified live
|
||||
**Found:** 2026-10-13, issue #39: the first sign-in on a fresh 3.4.0 install showed "This version
|
||||
of the app is no longer supported. Please update to continue using the app." Issue #28 showed the
|
||||
same text on 3.0.0 to 3.1.2, and its debug log gave `path="/auth/v4" http_status=422 code=2028`.
|
||||
Sign-in worked there after a day without attempts, with no change to the client.
|
||||
**Where:** `login_error_message` in `crates/pdfs-gui/src/app/pages/login.rs`; `cmd_login` and
|
||||
`cmd_diagnose` in `crates/pdfs-cli/src/main.rs`
|
||||
|
||||
**Repro:** Needs Proton to block the account or network, which cannot be triggered on purpose.
|
||||
|
||||
**Cause.** Proton answers a sign-in it blocks for abuse or rate limits with code 2028. Its `Error`
|
||||
text claims the version is no longer supported. The SDK had no name for 2028, so it read as
|
||||
`Unknown`, and the app passed Proton's text through. Users were told to update a current release.
|
||||
`pdfs diagnose` also failed the `database` check on a fresh install, before the daemon had ever
|
||||
started, which looked like part of the fault.
|
||||
|
||||
**Fix.** proton-sdk 0.8.3 names 2028 `ResponseCode::Blocked`. `error::sign_in_blocked` detects it,
|
||||
and the app and `pdfs login` say that Proton is refusing sign-in for now and that waiting helps.
|
||||
`pdfs diagnose` warns instead of failing when `cache.db` does not exist yet.
|
||||
|
||||
**Test:** `error::tests::only_code_2028_is_a_blocked_sign_in`,
|
||||
`pages::login::tests::a_blocked_sign_in_does_not_ask_for_an_update`.
|
||||
|
||||
---
|
||||
|
||||
### B208 — A file landing while its folder is pinned is kept but not available offline
|
||||
|
||||
**Status:** Fixed, not verified live
|
||||
**Found:** 2026-10-11, CI simulation run of `3e4886f`.
|
||||
`sim::daemon::tests::a_pinned_folder_keeps_every_file_under_it_until_it_is_unpinned` waited
|
||||
30 s for `Docs/sub/b.txt` to read as cached and gave up. It failed about once in 25 runs under load
|
||||
and never alone.
|
||||
**Where:** `Core::pin_subtree` in `crates/pdfs-fuse/src/lib.rs`
|
||||
|
||||
**Repro:** Pin a folder while a create in it lands, after the drain has repointed the tree at
|
||||
the real uid and before it has read the node back. The file's cached blob must be gone, as after
|
||||
`pdfs cache clear`, so the pin downloads it.
|
||||
|
||||
**Cause.** The pin walks the folder first and downloads each file under the modification time
|
||||
and size it read then. The landing's read-back then sets the time Drive gave the file, and keeps
|
||||
the uploaded bytes under it. The pin's download ended after that and stored the blob again under
|
||||
the old time. The cache records one time per blob, so the file read as stale: shown as kept
|
||||
offline, but downloaded again on the next read, and unreadable offline.
|
||||
|
||||
**Fix.** After each download the pin compares the file's time and size with the tree's. If they
|
||||
moved, it fetches the file again under the new ones, at most three times.
|
||||
|
||||
**Test:** `sim::daemon::tests::a_folder_pinned_while_a_create_in_it_lands_keeps_that_file` holds
|
||||
the read-back and the pin's download with `FakeClient::hold_next_download`, new for this, and
|
||||
lets them go in that order.
|
||||
|
||||
---
|
||||
|
||||
### B207 — A move between locations goes ahead under a file opened just after its create landed
|
||||
|
||||
**Status:** Fixed, not verified live
|
||||
**Found:** 2026-10-11, account acceptance run of 3.3.0, case "move refuses a file still being
|
||||
written" on the on-demand → mirror pairing. `pdfs move` moved the folder while `open.txt` in it
|
||||
was open for writing, and the write landed after the move.
|
||||
**Where:** `Core::has_pending_work_under` in `crates/pdfs-fuse/src/relocate.rs`; `retire_create`
|
||||
in `crates/pdfs-fuse/src/drain.rs`
|
||||
|
||||
**Repro:** Create a file in a mount, and open it for writing right after its create lands, before
|
||||
the drain has repointed the tree at the real uid. Then `pdfs move` its folder to another
|
||||
location. The account run did this by chance: it opened the file about a second after the
|
||||
create landed.
|
||||
|
||||
**Cause.** `retire_create` takes the create out of the queue and re-keys its `nodes` row to the
|
||||
real uid first, then repoints the in-memory tree and the open write handles. A file opened in
|
||||
between gets a write handle that holds the placeholder uid. `has_pending_work_under` placed each
|
||||
open handle by looking its uid up in the `nodes` table, which no longer has the placeholder, so it
|
||||
found nothing open under the folder. The queue was already empty, and the move went ahead.
|
||||
|
||||
**Fix.** Open handles are placed in the in-memory tree by inode, which does not change when a
|
||||
create lands.
|
||||
|
||||
**Test:** `sim::daemon::tests::a_move_out_of_an_on_demand_folder_refuses_a_file_still_open_for_writing`
|
||||
holds the drain at that point with `FakeClient::hold_next_landing_of_any`. The simulation can now
|
||||
sync local folders: the fake Drive lists and registers devices, and the simulated daemons no longer
|
||||
read the developer's own `config.json`.
|
||||
|
||||
---
|
||||
|
||||
### B206 — An upgrade leaves the old daemon running, and nothing says so
|
||||
|
||||
**Status:** Fixed, not verified live
|
||||
**Found:** 2026-10-11, issue [#33](https://github.com/narrrl/proton-drive-linux/issues/33). On
|
||||
3.2.0 the reporter still saw `Couldn't sync Documents: Read …/snapshots/37: Permission denied`,
|
||||
the B196 failure 3.2.0 fixed. Since 3.2.0 the scan leaves an unreadable subfolder out and
|
||||
`walk_local_dir` can only fail the pass on the sync root, so that message comes from a daemon
|
||||
older than 3.2.0.
|
||||
**Where:** `Response::Status` and `daemon_outdated` in `crates/pdfs-core/src/control.rs`;
|
||||
`status_response` in `crates/pdfs-fuse/src/control.rs`; the tray in `crates/pdfs-gui/src/main.rs`;
|
||||
the sync card in `crates/pdfs-gui/src/app/pages/status.rs`; `pdfs status` and `pdfs diagnose`
|
||||
|
||||
**Repro:** With the service running, install a newer package, then open the app.
|
||||
|
||||
**Cause.** A package upgrade replaces the binaries but does not restart the user service, so the
|
||||
running daemon keeps the old release and its bugs until the next login or a manual restart. The
|
||||
app, the tray and the CLI start from the new binaries and could not tell, so they described the
|
||||
new release while the old one did the work.
|
||||
|
||||
**Fix.** The daemon reports its release in `Status`. A daemon from before the field omits it,
|
||||
which reads as older. When the daemon is older than the front-end, the tray says "Update not
|
||||
finished — restart Proton Drive" with a Restart Proton Drive item, the sync card says "Restart to
|
||||
finish updating" with a Restart button, and `pdfs status` and `pdfs diagnose` print the
|
||||
`systemctl --user restart proton-drive` command. A newer daemon is not flagged: then the
|
||||
front-end is the stale one, and restarting the service would not help.
|
||||
|
||||
**Test:** `control::tests::only_an_older_daemon_is_outdated`;
|
||||
`tests::a_daemon_older_than_the_tray_needs_a_restart` in `pdfs-tray`.
|
||||
|
||||
---
|
||||
|
||||
### B205 — A photo upload the SDK refuses says "You're offline"
|
||||
|
||||
**Status:** Fixed, not verified live
|
||||
**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.
|
||||
**Where:** `CoreError::from_api` and `From<Error> for CoreError` in `crates/pdfs-core/src/error.rs`;
|
||||
the `UploadPhoto` handler in `crates/pdfs-fuse/src/control.rs`; the Photos page in
|
||||
`crates/pdfs-gui/src/app/pages/photos.rs`
|
||||
|
||||
**Repro:** On an account without a photo library, open Photos and upload an image.
|
||||
|
||||
**Cause.** Every `ProtonError` other than `Api` was classified as `ErrorKind::Offline`, on the
|
||||
reasoning that it never reached Proton. That holds for `Transport` only. `InvalidOperation`,
|
||||
`Crypto`, `KeysLocked` and `Serialization` happen on this machine or after Proton answered. A
|
||||
failure that fast is most likely the SDK's "account has no photos volume", which it returns from
|
||||
its cache without sending a request. The app turns `Offline` into "You're offline", and the
|
||||
daemon only sent the error back, so the journal did not say what it was.
|
||||
|
||||
**Fix.** `proton_kind` classifies by variant: `Transport` is `Offline`, `InvalidOperation` is
|
||||
`Invalid`, `Serialization` is `Remote`, and `Crypto` and `KeysLocked` are `Internal`. The app then
|
||||
shows "Couldn't upload photo" with the daemon's reason. The daemon logs a failed photo upload. The
|
||||
Photos page greys out Upload when the daemon reports no photo library.
|
||||
|
||||
**Test:** `error::tests::a_call_the_sdk_refuses_to_send_is_not_offline`.
|
||||
|
||||
---
|
||||
|
||||
### B204 — A write over a partial edit whose base was never read fails with `EIO`
|
||||
|
||||
**Status:** Fixed, not verified live
|
||||
**Found:** 2026-10-09, by CI on 2a86830 (`one_client_on_a_good_link`, seed 1, which did not replay
|
||||
locally). A partial write to `x/f.txt`, a file renamed twice before, was renamed over `z/d`. A
|
||||
create over `z/d` a moment later failed with `EIO`, and the daemon logged "refusing write over
|
||||
incomplete queued revision".
|
||||
**Where:** `ProtonFs::serve_open` in `crates/pdfs-fuse/src/filesystem.rs`
|
||||
|
||||
**Repro:** Write at an offset into a file this client never read, then open it for writing again
|
||||
before the drain uploads the first write.
|
||||
|
||||
**Cause.** The release of a partial write fills its gaps from the content cache only. When the
|
||||
cache does not hold the base, the queued revision stays incomplete until the drain fills it from
|
||||
Drive, and a write open over an incomplete revision was refused. B123, B157, B169 and B170 each
|
||||
closed one way the cache could miss the base. Any other miss, such as a file never read here,
|
||||
still failed the next write open for the length of the drain debounce.
|
||||
|
||||
**Fix.** A write open over an incomplete queued revision copies the blob and fills its gaps from
|
||||
the remote base with `Core::fill_gaps`, as the drain does before an upload. The open fails only
|
||||
when that fill fails, for example offline.
|
||||
|
||||
**Test:** `a_partial_write_over_bytes_never_read_takes_another_write_at_once` in `sim/daemon.rs`.
|
||||
Without the fix the second write fails with `EIO`.
|
||||
|
||||
---
|
||||
|
||||
### B203 — `pdfs unlock` leaves a running daemon a spent refresh token
|
||||
|
||||
**Status:** Fixed, not verified live
|
||||
**Found:** 2026-10-09, reading the code for issue #35 after the B201 fix
|
||||
**Where:** `crates/pdfs-core/src/auth.rs`, `stored_session_is_newer`; `crates/pdfs-fuse/src/link.rs`,
|
||||
`Core::mark_session_expired`; `crates/pdfs-fuse/src/mount.rs`; `crates/pdfs-cli/src/main.rs`,
|
||||
`cmd_daemon`
|
||||
|
||||
**Repro:** With the daemon running, run `pdfs unlock` while the stored access token has expired.
|
||||
Unlock resumes the same session, refreshes it and stores the new tokens. The daemon still holds
|
||||
the old refresh token. If it refreshes before unlock restarts it, Proton refuses the spent token,
|
||||
and since B202 the mount reports the session expired, although the keyring holds a live one.
|
||||
|
||||
**Cause.** Two processes ran the same session, and only one of them could hold its live refresh
|
||||
token. B201 stopped the daemon from writing its spent token over the live one, but nothing made the
|
||||
daemon pick up the live one.
|
||||
|
||||
**Fix.** Before a refused session counts as expired, `auth::stored_session_is_newer` compares the
|
||||
keyring with what this process last stored. When the keyring holds a newer rotation of the same
|
||||
session, or another login, the mount ends with `MountOutcome::ResumeStored`, and the daemon mounts
|
||||
again at once from the keyring. At startup the same check turns the refusal into a mount error,
|
||||
which the daemon retries. A rotation that another process already wrote into the blob is now
|
||||
taken as this session's, so the daemon keeps writing its later rotations.
|
||||
|
||||
**Test:** `auth::tests::a_session_rotated_by_another_process_is_newer_in_the_keyring` and
|
||||
`auth::tests::a_rotation_already_in_the_blob_is_taken`.
|
||||
|
||||
---
|
||||
|
||||
### B202 — A refused session shows as offline forever, and nothing says to sign in again
|
||||
|
||||
**Status:** Fixed, not verified live
|
||||
**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.
|
||||
**Where:** `crates/pdfs-fuse/src/link.rs`, `Core::lost_link` and `Core::probe_until_online`;
|
||||
`crates/pdfs-fuse/src/mount.rs`, `fetch_or_recall_root`; `crates/pdfs-fuse/src/background.rs`;
|
||||
`crates/pdfs-core/src/control.rs`, `Response::Status`; the tray, the app's status, `pdfs status`
|
||||
and `pdfs diagnose`
|
||||
|
||||
**Repro:** Make the stored refresh token invalid (B201 is one way), then start the daemon. It
|
||||
mounts from the cache and says offline; the probe retries every 30 s, then every 5 minutes, and
|
||||
logs the refusal only at debug. A daemon that started online and loses its session later does not
|
||||
even go offline: its failures show up as EIO and drain backoff.
|
||||
|
||||
**Cause.** Every failure to fetch the root at startup counted as offline, and nothing told a
|
||||
refused session apart from a lost connection. No status field could carry it, so no front end
|
||||
could say it.
|
||||
|
||||
**Fix.** `pdfs_core::error::session_revoked` names the errors only a new login fixes:
|
||||
`InvalidRefreshToken`, `AccountDeleted`, `AccountDisabled`. When the root fetch, the probe, the
|
||||
drain, an event poll, the event seed or the photos root sees one, the mount goes offline with
|
||||
`session_expired` set, logs one warning naming `pdfs login`, and stops probing and retrying.
|
||||
Cached files stay readable and changes stay queued for the daemon the next login starts.
|
||||
`Response::Status` carries `session_expired`. The tray shows "Session expired — sign in again"
|
||||
with **Sign In Again…**, which opens the app's sign-in page; the app's status and Sync card say the
|
||||
same, with a **Sign In Again** button. `pdfs status` and `pdfs diagnose` report it, the latter as
|
||||
a FAIL. A refused session is not recorded as a sync issue of the op that ran into it.
|
||||
|
||||
**Test:** `error::tests::only_a_refused_refresh_or_a_closed_account_revokes_the_session`, the
|
||||
tray's `tests::a_refused_session_outranks_everything_else` and
|
||||
`tests::a_daemon_that_does_not_say_has_a_live_session`.
|
||||
|
||||
---
|
||||
|
||||
### B201 — After signing in again, the old daemon puts its spent tokens back
|
||||
|
||||
**Status:** Fixed, not verified live
|
||||
**Found:** 2026-10-09, reading the code for issue #35, where every call failed with
|
||||
`InvalidRefreshToken` and signing in again did not obviously help.
|
||||
**Where:** `crates/pdfs-core/src/auth.rs`, `persist`, `register_refresh_handler`, `unlock` and
|
||||
`migrate_legacy`; `crates/pdfs-cli/src/main.rs`, `cmd_daemon`
|
||||
|
||||
**Repro:** With the daemon running, run `pdfs login`. It stores the new session and restarts the
|
||||
unit. The old daemon's stop runs `auth::persist`, which wrote its own access and refresh tokens
|
||||
into the blob, now the new session's. The new daemon starts with a refresh token of another
|
||||
session, and the first refresh fails with `InvalidRefreshToken`. `pdfs unlock` had the same
|
||||
shape: its session could rotate the tokens, and the restarted daemon's stop put the spent refresh
|
||||
token back. `unlock` and `migrate_legacy` could do it themselves, writing a copy of the blob read
|
||||
before verifying the mailbox password, which may have rotated the tokens.
|
||||
|
||||
**Cause.** Proton refresh tokens are single-use. `store_tokens` loaded whatever blob was stored
|
||||
and replaced its tokens, without checking that the blob was still this session's.
|
||||
|
||||
**Fix.** Each session writes its rotations through a `TokenSink`. It writes only into a blob with
|
||||
its session id that still holds the refresh token the session last left there; a blob that moved
|
||||
on, to a new login or to another session's rotation, is left alone. The stop no longer writes the
|
||||
session's tokens, only a rotation the keyring refused when it happened. `unlock` and
|
||||
`migrate_legacy` read the blob again after verifying. A failed final write is an error in the log,
|
||||
not a debug line claiming there was nothing to save.
|
||||
|
||||
**Test:** `auth::tests::tokens_of_another_session_are_not_written`,
|
||||
`auth::tests::tokens_are_not_written_over_a_newer_rotation_of_the_same_session`,
|
||||
`auth::tests::a_sink_keeps_writing_its_own_rotations_and_stops_at_a_new_login`.
|
||||
|
||||
---
|
||||
|
||||
### B200 — Offline with changes queued, the daemon keeps several cores busy
|
||||
|
||||
**Status:** Fixed, not verified live
|
||||
**Found:** 2026-10-09, issue #35: "offline, 3 queued", and systemd counted 17 minutes of CPU in
|
||||
2 m 16 s of wall time, 10 hours over a longer run.
|
||||
**Where:** `crates/pdfs-fuse/src/drain.rs`, `Core::run_pending_drain` and `idle_wait`;
|
||||
`crates/pdfs-core/src/db/ops.rs`, `Db::earliest_due_at`
|
||||
|
||||
**Repro:** Queue a write, then cut the link (`nmcli networking off`). `pidstat -t -p $(pgrep -x
|
||||
pdfs) 5` shows the drain threads using most of several cores for as long as the outage lasts.
|
||||
|
||||
**Cause.** Offline, a drain worker claims nothing, and then sleeps until the earliest queued op
|
||||
falls due. Every queued op was due already, and with no attempts nothing moves its
|
||||
`next_attempt_at`, so the sleep was zero. All 16 workers ran a query on every pass, the primary
|
||||
one its idle chores as well. The same spin happened online when a due op sat behind a claimed op
|
||||
on the same node, such as a rename queued behind a long upload: `earliest_due_at` counted it,
|
||||
`claim_next_due_op` skipped it.
|
||||
|
||||
**Fix.** A worker that claims nothing, offline or paused, sleeps the full idle poll; a reconnect
|
||||
or a resume wakes it. `earliest_due_at` now uses the claim's own exclusions, one SQL fragment for
|
||||
both.
|
||||
|
||||
**Test:** `drain::tests::an_offline_worker_waits_the_idle_poll_for_a_past_due_op`,
|
||||
`db::tests::earliest_due_at_skips_an_op_held_behind_a_claimed_one`.
|
||||
|
||||
---
|
||||
|
||||
### B199 — A stopped share stays on the Shared by me page for minutes
|
||||
|
||||
**Status:** Partly fixed. The share itself is still not deleted.
|
||||
**Found:** 2026-10-09, reported from the app: after **Stop Sharing**, the row stayed, and an
|
||||
older share showed with no people and no link.
|
||||
**Where:** `crates/pdfs-fuse/src/sharing.rs`, `Core::stop_sharing_for_uid` and
|
||||
`Core::list_shared_by_me`; `crates/pdfs-gui/src/app/pages/shared_by_me.rs`, `prompt_stop_sharing`
|
||||
|
||||
**Repro:** Share a folder with a person, then choose **Stop Sharing…** on the Shared by me page.
|
||||
The row stays, now with "—" for people and link, until a reload some minutes later.
|
||||
|
||||
**Cause.** The Rust SDK has no call that deletes a share. Stop Sharing removes the public link, the
|
||||
invitations and the members one by one, and leaves an empty share behind. Drive deletes an empty
|
||||
share on its own, but only after a while, and until then `GET v2/volumes/{vid}/shares` still lists
|
||||
it. The JS SDK ends `unshareNode` with `DELETE drive/shares/{shareId}?Force=1` for exactly this
|
||||
reason.
|
||||
|
||||
**Fix.** The daemon leaves a share with no member, no invitation and no link out of the Shared by
|
||||
me listing, unless one of those lookups failed. The app also drops a stopped item from the page as
|
||||
soon as the request succeeds, before the confirming reload. Left: a `delete_share` call in
|
||||
`proton-drive-rs`, made at the end of `stop_sharing_for_uid`, so the share is really gone.
|
||||
|
||||
**Test:** none automated; the listing needs a live account.
|
||||
|
||||
---
|
||||
|
||||
### B198 — Switching to online-only deletes local files that never reached Drive
|
||||
|
||||
**Status:** Fixed (unverified).
|
||||
**Found:** 2026-10-08, reading the switch while fixing B196.
|
||||
**Where:** `crates/pdfs-fuse/src/devices.rs`, `Core::apply_sync_folder_mode`;
|
||||
`crates/pdfs-fuse/src/sync.rs`, `mirror_tree_unsynced`
|
||||
|
||||
**Repro:** Mirror a project folder with a `.git/` in it, then switch it to **Online only**.
|
||||
`.git/` is gone from the disk and was never on Drive.
|
||||
|
||||
**Cause.** The switch evicts the whole local tree once the folder is `idle`. `idle` only says the
|
||||
last pass applied everything it tried, and a pass never tries an ignored path (`.git/` is ignored
|
||||
by default), a symlink, or a folder it cannot read (B196). It also defers a file that is open for
|
||||
writing without leaving `idle`, and a file can change after the pass ends. The eviction deleted
|
||||
all of those.
|
||||
|
||||
**Fix.** Before the mode is written and anything is evicted, the switch walks the folder against
|
||||
the baseline, as a move out of a mirror folder already does, and refuses when something is only on
|
||||
this disk. The message names the path; an ignored one is called excluded from sync. A tracked file
|
||||
deleted here but not yet on Drive also refuses. An ignored one does not, since its baseline row
|
||||
stays for good and would hold the switch forever.
|
||||
|
||||
**Test:** unit tests `a_fully_synced_mirror_folder_may_go_online_only`,
|
||||
`an_ignored_folder_only_this_disk_has_holds_the_switch_back`,
|
||||
`a_deleted_ignored_path_does_not_hold_the_switch_back`,
|
||||
`an_edit_after_the_last_pass_holds_the_switch_back`, `a_new_file_at_the_root_holds_the_switch_back`,
|
||||
`an_unreadable_folder_holds_the_switch_back` and `a_symlink_holds_the_switch_back`. The switch
|
||||
itself has not been driven against a live account.
|
||||
|
||||
---
|
||||
|
||||
### B197 — Switching to online-only trashes synced files that became ignored
|
||||
|
||||
**Status:** Fixed (unverified).
|
||||
**Found:** 2026-10-08, reading the push pass while fixing B196.
|
||||
**Where:** `crates/pdfs-fuse/src/sync.rs`, `Core::push_pass`; `crates/pdfs-fuse/src/sync/planner.rs`,
|
||||
`local_deletions`
|
||||
|
||||
**Repro:** Mirror a folder until `build/` is on Drive, add `build/` to `.pdfsignore`, then switch
|
||||
the folder to **Online only**. `build/` goes to the Drive trash, and the switch then deletes the
|
||||
local copy.
|
||||
|
||||
**Cause.** The push pass that runs before the switch read its deletions as "in the baseline, not in
|
||||
the local walk". The walk skips ignored paths, so every synced path that later became ignored read
|
||||
as deleted. A full pass does not do this: it drops ignored paths from classification, which is
|
||||
what the user guide promises ("Ignoring never deletes anything").
|
||||
|
||||
**Fix.** The push pass reads its deletions from the same filtered baseline the wipe guard checks:
|
||||
without ignored paths, and without paths the scan could not read (B196).
|
||||
|
||||
**Test:** unit tests `a_push_pass_never_reads_an_ignored_path_as_deleted` and
|
||||
`local_deletions_come_out_shallowest_first`.
|
||||
|
||||
---
|
||||
|
||||
### B196 — One unreadable folder stops the whole synced folder from syncing
|
||||
|
||||
**Status:** Fixed (unverified). The reporter has not run the fix yet.
|
||||
**Found:** 2026-10-08, [#33](https://github.com/narrrl/proton-drive-linux/issues/33). A synced
|
||||
`~/Documents` held a restic cache a Docker container had created, which the user may not read.
|
||||
Every pass failed with `Couldn't sync Documents: read …/restic/cache/…/snapshots: Permission
|
||||
denied`, and nothing else in `Documents` synced.
|
||||
**Where:** `crates/pdfs-fuse/src/sync.rs`, `walk_local_tree`; `crates/pdfs-fuse/src/sync/planner.rs`,
|
||||
`Unscanned`
|
||||
|
||||
**Repro:** In a mirror folder, `sudo mkdir locked && sudo chmod 700 locked`, then sync.
|
||||
|
||||
**Cause.** B55 made every scan failure fatal to the pass, because a path left out of the scan read
|
||||
as a local deletion and was trashed on Drive. That was safe, but a folder the user may not read is
|
||||
not transient: the pass failed on every poll, for good. The same held for a file deleted between
|
||||
the listing and its `stat`, which a busy folder hits now and then, and for a name that is not UTF-8.
|
||||
|
||||
**Fix.** The scan leaves such a path out and records it, and the pass drops everything under it
|
||||
from classification, the wipe guard and the push pass's deletions, as it already did for ignored
|
||||
paths. A left-out path is then neither deleted on Drive nor downloaded into. A folder that lists
|
||||
but does not allow `stat` is left out whole. An unreadable or absent root, and every other I/O
|
||||
error, still fails the pass. The activity feed names the unreadable paths once each time the set
|
||||
changes, and the folder stays `idle`. Adding the folder to `.pdfsignore` was already a workaround
|
||||
and still silences the report.
|
||||
|
||||
**Test:** unit tests `an_unreadable_subdirectory_is_left_out_and_the_rest_scans`,
|
||||
`a_listable_but_unsearchable_folder_is_left_out_whole`,
|
||||
`a_path_the_scan_left_out_is_never_a_local_deletion`,
|
||||
`unscanned_covers_a_path_and_what_is_under_it_but_not_its_siblings` and
|
||||
`a_non_utf8_name_is_left_out_and_reported`. `an_unreadable_root_fails_the_scan` and
|
||||
`an_absent_root_fails_the_scan` still hold.
|
||||
|
||||
---
|
||||
|
||||
### B195 — The app crashes when Proton asks for a CAPTCHA on secureblue
|
||||
|
||||
**Status:** Fixed (unverified). On 2026-10-08 the reporter confirmed that the app survives, but
|
||||
the CAPTCHA still could not be solved; the browser fallback below has not run on secureblue yet.
|
||||
**Found:** 2026-10-07, [#29](https://github.com/narrrl/proton-drive-linux/issues/29). On
|
||||
secureblue 44 (a hardened Fedora Atomic), `pdfs-app` aborted a few seconds after the sign-in, with
|
||||
`bwrap: Can't get type of source /run/systemd/journal/socket: Permission denied` and
|
||||
`Failed to fully launch dbus-proxy`.
|
||||
**Where:** `crates/pdfs-gui/src/app/pages/verify.rs`, `crates/pdfs-gui/src/app/pages/login.rs`,
|
||||
`crates/pdfs-core/src/auth.rs`, `crates/pdfs-cli/src/main.rs`
|
||||
|
||||
**Repro:** Sign in on secureblue from a network Proton asks a CAPTCHA for, such as a VPN.
|
||||
|
||||
**Cause.** The CAPTCHA page runs in a WebKitGTK view (B8). Before it loads anything, WebKit starts
|
||||
its sandbox: bubblewrap, with the journal socket bound in, and `xdg-dbus-proxy`. secureblue keeps
|
||||
bubblewrap from the socket, the proxy does not start, and WebKit ends its process with `g_error`.
|
||||
WebKit binds the socket with `--ro-bind-try`, but bubblewrap skips only a missing path, not one it
|
||||
may not read, and WebKit has no switch to leave the socket out.
|
||||
The view was in the app's own process, so the whole app went down, and nothing in our code can
|
||||
catch that.
|
||||
|
||||
**Fix.** The view runs in a child, `pdfs-app --human-verification`, that reads the URL on stdin
|
||||
and prints the token on stdout. A child that exits without a token after a crash or a non-zero
|
||||
status no longer ends the sign-in: the app offers the page in the user's own browser, as Proton
|
||||
Mail Bridge does. The tab cannot post the token back, so the user says when they are done, and the
|
||||
retry carries the challenge token, typed with every offered method. `pdfs login` takes the same
|
||||
path. The sandbox is not switched off by the app: users choose a hardened system for that
|
||||
isolation. TROUBLESHOOTING.md keeps the one-time workaround as a last resort.
|
||||
|
||||
**Test:** unit tests `a_window_that_died_fails`, `a_window_closed_without_a_token_cancels`,
|
||||
`a_token_counts_even_if_the_window_died_after_it`, `the_token_is_found_among_other_output`,
|
||||
`the_browser_gets_the_full_verification_page` and
|
||||
`a_browser_verification_retries_with_the_challenge_token`. The browser retry has not run against a
|
||||
live challenge: Proton only asks for one from a network it distrusts.
|
||||
On 2026-10-08 the child ran under real WebKit on Arch against a local page that posts a completion,
|
||||
and handed back its token.
|
||||
|
||||
---
|
||||
|
||||
### B194 — On a fresh install the tray exits and the Takeout page cannot remember its archives
|
||||
|
||||
**Status:** Fixed.
|
||||
**Found:** 2026-10-07, in the log of [#29](https://github.com/narrrl/proton-drive-linux/issues/29):
|
||||
`failed to bind tray single-instance socket: No such file or directory` and
|
||||
`cannot remember staged Takeout archives: No such file or directory`.
|
||||
**Where:** `crates/pdfs-gui/src/main.rs`, `build_window` in `crates/pdfs-gui/src/app/main.rs`
|
||||
|
||||
**Repro:** Start `pdfs-app` with no `~/.local/state/proton-drive-linux`, as on a fresh install. The
|
||||
tray is gone, and staging a Takeout archive logs the warning.
|
||||
|
||||
**Cause.** Both write to the state directory, and only the daemon and the CLI made it
|
||||
(`AppDirs::ensure`). The app starts the tray before the first sign-in, so before the daemon has
|
||||
ever run.
|
||||
|
||||
**Fix.** `pdfs-tray` and `pdfs-app` call `AppDirs::ensure` at start, which also makes the
|
||||
directories owner-only (B6).
|
||||
|
||||
**Verified:** 2026-10-08, `pdfs-tray` with `XDG_STATE_HOME`, `XDG_CONFIG_HOME` and
|
||||
`XDG_CACHE_HOME` pointing at an empty directory. It exited with the error before the fix, and
|
||||
afterwards ran with `tray.sock` in a `0700` state directory.
|
||||
|
||||
---
|
||||
|
||||
### B193 — Deleting a file whose create failed can trash a file of the user's on Drive
|
||||
|
||||
**Status:** Fixed (unverified).
|
||||
@@ -4639,7 +5158,7 @@ daemon/tray/FUSE, upgrade, rollback, and uninstall without deleting user state.
|
||||
|
||||
### B61 — Rotated single-use credentials can be lost (HIGH-18)
|
||||
|
||||
**Status:** Open — 1.0 authentication/recovery blocker
|
||||
**Status:** Fixed, not verified live
|
||||
**Found:** 2026-07-22, 1.0 authentication audit
|
||||
**Where:** `crates/pdfs-core/src/auth.rs`, refresh callback
|
||||
|
||||
@@ -4651,6 +5170,26 @@ keyring retains an invalid single-use refresh token, so restart loses login.
|
||||
notify the user, and define a safe re-login/shutdown path. Test locked/full/
|
||||
unavailable keyrings, callback races, repeated rotations, death, and restart.
|
||||
|
||||
**Fix (2026-10-09, issue #35).** A rotation the keyring refuses is kept as unsaved and retried
|
||||
in the background after 1 s, 5 s, 30 s and then every 5 minutes, until it is written or the blob
|
||||
has moved on (B201). The refusal is an error in the log naming `pdfs login`, and the daemon's stop
|
||||
writes a rotation still unsaved, logging an error if that fails too. The re-login path is B201's:
|
||||
an old session never writes into a newer login.
|
||||
|
||||
Until it is written, the rotation also waits in the kernel keyring (`kernelkey::UNSAVED_TOKENS`),
|
||||
so a crash or SIGKILL before a retry lands no longer loses it. The next resume, by the daemon or
|
||||
`pdfs unlock`, puts it back in when the blob still holds the refresh token it spent, and writes
|
||||
it. One that belongs to an older login is dropped. The kernel keyring lasts until the user's last
|
||||
session ends, so a refused write followed by a crash and a logout still loses the rotation.
|
||||
|
||||
While a rotation waits, `Response::Status` carries `tokens_unsaved`. The tray says "Sign-in not
|
||||
saved — unlock your keyring" and needs attention, the app's status says the same, `pdfs status`
|
||||
prints a `Keyring` line and `pdfs diagnose` warns.
|
||||
|
||||
**Test:** `auth::tests::a_rotation_the_keyring_refused_is_written_once_it_takes_it` and
|
||||
`auth::tests::an_unsaved_rotation_goes_only_into_the_blob_it_was_refused_for`, and the tray's
|
||||
`tests::a_sign_in_the_keyring_refused_needs_attention`.
|
||||
|
||||
---
|
||||
|
||||
### B60 — Config writes are non-atomic and parse errors are overwritten (HIGH-17)
|
||||
@@ -4766,6 +5305,10 @@ subdirectory holding a file, asserting the error names the subtree),
|
||||
strictness must not cost a working pass, so a healthy tree still reports every entry and still
|
||||
skips symlinks by design.
|
||||
|
||||
B196 later relaxed this: a path the user may not read, an entry that vanishes mid-scan and a name
|
||||
that is not UTF-8 are left out of the pass instead of failing it, and the pass drops everything
|
||||
under them from classification, so the B55 guarantee holds without the wedge.
|
||||
|
||||
Not covered: transient I/O errors, and an entry that disappears between `read_dir` and `stat`.
|
||||
Both are races that would need a filesystem shim to force; the code path they take is the same
|
||||
`symlink_metadata` failure the permission test drives.
|
||||
|
||||
@@ -11,6 +11,175 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [3.4.1] — 2026-10-16
|
||||
|
||||
Schema: **42**, unchanged.
|
||||
|
||||
### Fixed
|
||||
- **After a password reset, the drive never mounted and the app stayed on "Connecting…".** A
|
||||
reset gives the account new keys, and Proton locks the Drive volume that was encrypted to the
|
||||
old ones. The service then failed every start with `failed to decrypt: missing key`. It now
|
||||
starts a new My Files volume, as the web app does. The files in the locked volume can still be
|
||||
restored from [drive.proton.me](https://drive.proton.me) with the previous password
|
||||
(B210, [#40](https://github.com/narrrl/proton-drive-linux/issues/40)).
|
||||
- **A blocked sign-in said the app version was no longer supported.** Proton refuses sign-in for
|
||||
a while after it suspects abuse, and its message then asks you to update. The app and
|
||||
`pdfs login` now say that Proton is refusing sign-in for now and that waiting helps
|
||||
(B209, [#39](https://github.com/narrrl/proton-drive-linux/issues/39)).
|
||||
- **`pdfs diagnose` failed the `database` check on a fresh install.** The daemon creates the
|
||||
database after the first sign-in, so a missing one is now a warning.
|
||||
|
||||
## [3.4.0] — 2026-10-12
|
||||
|
||||
Schema: **42**, unchanged.
|
||||
|
||||
### Added
|
||||
- **A binary cache for the Nix flake.** Every release and every build of `main` is uploaded to
|
||||
`proton-drive-linux.cachix.org`, so NixOS installs download the client instead of compiling
|
||||
it. See [Install → Binary cache](INSTALL.md#binary-cache).
|
||||
([#37](https://github.com/narrrl/proton-drive-linux/issues/37))
|
||||
- **Folders can be made available offline in the app.** The context menu, the details pane and
|
||||
the selection bar now offer it for folders too. Every file in the folder and its subfolders
|
||||
downloads in the background with its progress in the tray and the app, and pinned folders carry
|
||||
the offline badge. A file kept through its folder shows as available offline but cannot be
|
||||
made online only on its own. `pdfs pin` on a folder now returns at once instead of timing out
|
||||
on a large folder, and pinned files download to disk instead of into memory.
|
||||
|
||||
### Fixed
|
||||
- **Removing the package left the drive mounted, and deletes there still reached Proton Drive.**
|
||||
The service kept running from the removed program, so deleting `~/ProtonDrive` afterwards moved
|
||||
its files to the Proton Drive trash. The .deb, .rpm and Arch packages now stop the service of
|
||||
every logged-in user before they are removed. Upgrades are not affected.
|
||||
- **`pdfs move` between two locations could move a folder with a file open for writing in it**,
|
||||
when the file had been opened just after it first reached Proton Drive. The write then landed
|
||||
after the move. Such a move is refused now, as it always was meant to be (B207).
|
||||
- **The Fedora package installed KDE packages on GNOME.** Its recommendation of KWallet is now
|
||||
only for Plasma, the GNOME keyring only for desktops other than Plasma, and the AppIndicator
|
||||
extension only for GNOME.
|
||||
|
||||
## [3.3.0] — 2026-10-11
|
||||
|
||||
Schema: **42**, unchanged.
|
||||
|
||||
### Added
|
||||
- **A Nix flake for NixOS.** `nix build github:narrrl/proton-drive-linux` builds the client, and
|
||||
the flake's NixOS module installs it and runs the mount in every graphical session. Pin the
|
||||
input to a release tag such as `github:narrrl/proton-drive-linux/v3.3.0` to stay on releases.
|
||||
See [Install → NixOS](INSTALL.md#nixos-flake).
|
||||
([#37](https://github.com/narrrl/proton-drive-linux/issues/37))
|
||||
|
||||
### Fixed
|
||||
- **A failed photo upload said "You're offline" while everything else worked.** Only a failed
|
||||
connection now reads as offline. Other failures show what went wrong, and Upload is greyed out
|
||||
on an account without a photo library.
|
||||
([#38](https://github.com/narrrl/proton-drive-linux/issues/38))
|
||||
- **After an upgrade, the old version kept running in the background, and nothing said so.**
|
||||
Installing a package does not restart the service, so fixed bugs, such as an unreadable folder
|
||||
stopping a synced folder, came back until the next login. The tray, the app, `pdfs status` and
|
||||
`pdfs diagnose` now say when the service is older than the app, and the tray and the app offer
|
||||
to restart it. ([#33](https://github.com/narrrl/proton-drive-linux/issues/33))
|
||||
|
||||
## [3.2.1] — 2026-10-11
|
||||
|
||||
Schema: **42**, unchanged.
|
||||
|
||||
### Fixed
|
||||
- **Writing to a file again soon after a partial write could fail with an I/O error.** When the
|
||||
first write changed only part of a file this client had not read, a second write, or saving
|
||||
over the file, failed until the first one reached Drive. It now fetches the rest of the file
|
||||
and goes ahead.
|
||||
- **A CAPTCHA could not be solved on hardened systems.** When the verification page cannot open in
|
||||
the app, as on secureblue, the app now offers it in your browser and continues the sign-in
|
||||
once you are done. `pdfs login` now handles a CAPTCHA the same way instead of failing.
|
||||
([#29](https://github.com/narrrl/proton-drive-linux/issues/29))
|
||||
- **The daemon kept several CPU cores busy while offline with changes queued.** Its upload
|
||||
workers checked the queue in a tight loop for as long as the outage lasted. They now sleep
|
||||
until the connection comes back. ([#35](https://github.com/narrrl/proton-drive-linux/issues/35))
|
||||
- **An expired sign-in was shown as "offline" for good.** When Proton refuses the session, the
|
||||
tray now says "Session expired — sign in again" with a Sign In Again… item, the app shows the
|
||||
same with a button, and `pdfs status` and `pdfs diagnose` tell you to run `pdfs login`. Cached
|
||||
files stay readable and changes stay queued until you sign in.
|
||||
([#35](https://github.com/narrrl/proton-drive-linux/issues/35))
|
||||
- **The sign-in could be lost after `pdfs login`, `pdfs unlock` or a slow network.** The old
|
||||
daemon wrote its spent tokens over a new sign-in when it stopped, a timeout could drop a token
|
||||
refresh halfway, and a refresh was retried with a token Proton had already used. Each process
|
||||
now writes only into its own session, a refresh always finishes, and it is never repeated.
|
||||
([#35](https://github.com/narrrl/proton-drive-linux/issues/35))
|
||||
- **A sign-in refreshed while the keyring was locked was lost on the next restart.** The client
|
||||
now keeps retrying, holds the new tokens in the kernel keyring meanwhile, and the tray says
|
||||
"Sign-in not saved — unlock your keyring" until it is written.
|
||||
|
||||
## [3.2.0] — 2026-10-08
|
||||
|
||||
Schema: **42**, unchanged.
|
||||
|
||||
### Fixed
|
||||
- **One unreadable folder stopped a synced folder from syncing at all.** A folder you may not
|
||||
read, such as a cache a container created, failed every pass, so nothing else in the synced
|
||||
folder reached Drive. Such a folder is now left out and named once in the activity feed, and
|
||||
everything around it syncs. A file deleted during a scan, and a name that is not UTF-8, no
|
||||
longer fail the pass either.
|
||||
([#33](https://github.com/narrrl/proton-drive-linux/issues/33))
|
||||
- **Switching a folder to online-only could delete files that were never on Drive.** Ignored
|
||||
folders such as `.git/`, symlinks, unreadable folders and files changed after the last sync were
|
||||
deleted with the local copy. The switch now refuses and names what is only on this computer.
|
||||
- **Switching to online-only trashed synced files that had become ignored.** Adding a synced path
|
||||
to `.pdfsignore` and then switching moved it to the Drive trash. Ignoring never deletes now, as
|
||||
the user guide says.
|
||||
- **A stopped share stayed on the Shared by me page for minutes.** Stop Sharing now takes it off
|
||||
the page at once, and an emptied share no longer shows with no people and no link.
|
||||
- **The tray icon showed syncing every two minutes.** The routine check of a synced folder no
|
||||
longer counts as syncing. Only work that changes files spins the icon. An edit in one synced
|
||||
folder also no longer rescans all the others.
|
||||
- **Right-click menus sometimes opened too short and had to be scrolled.**
|
||||
- **The Share dialog looked unfinished.** Its buttons now sit under their sections instead of in a
|
||||
row of their own. The role is chosen beside the addresses, people show with avatars, and both
|
||||
sections show that they are loading.
|
||||
- **Breadcrumbs spaced the "›" unevenly, and grid file names were broken with hyphens they do not
|
||||
have.**
|
||||
|
||||
### Changed
|
||||
- **One main menu.** The menu button beside "Proton Drive" is gone. Preferences, Keyboard
|
||||
Shortcuts, About and Sign Out are all in the account row at the bottom of the sidebar. F10 opens
|
||||
it.
|
||||
|
||||
### Added
|
||||
- **Move another computer's backup to this one.** **Move to This Computer…** on the Computers page,
|
||||
or `pdfs devices migrate UID`, moves every folder another computer backs up, for example an old
|
||||
Windows PC, into this computer's backup on Proton Drive. Then it offers them for syncing here. The
|
||||
folders move on Proton Drive itself, so nothing is uploaded again. The other computer stays in the
|
||||
account, without those folders.
|
||||
|
||||
## [3.1.2] — 2026-10-08
|
||||
|
||||
Schema: **42**, unchanged.
|
||||
|
||||
### Fixed
|
||||
- **The app crashed when Proton asked for a CAPTCHA on hardened systems.** On secureblue, WebKit
|
||||
cannot start the sandbox for the verification page and ends its process, which took the app
|
||||
down with it. The page now opens in a separate window process: if that process fails, the
|
||||
sign-in stops with a message that says why, and TROUBLESHOOTING.md has a workaround.
|
||||
([#29](https://github.com/narrrl/proton-drive-linux/issues/29))
|
||||
- **On a fresh install the tray icon did not start.** The tray and the app now create their state
|
||||
directory themselves instead of waiting for the service to do it. This also lets the Takeout
|
||||
page remember staged archives before the first mount.
|
||||
([#29](https://github.com/narrrl/proton-drive-linux/issues/29))
|
||||
|
||||
## [3.1.1] — 2026-10-07
|
||||
|
||||
Schema: **42**, unchanged.
|
||||
|
||||
### Changed
|
||||
- **The client identifies itself with its release.** Sign-in and every API call used to send the
|
||||
fixed `external-drive-linux@0.1.0-alpha` and `proton-drive-linux/0.1.0`. If Proton retired that
|
||||
version, every release would fail to sign in at once and no update could fix it. The client now
|
||||
sends `external-drive-linux@<version>-stable` and `proton-drive-linux/<version>`. `app_version`
|
||||
and `user_agent` in `config.json` are optional overrides now. The fixed values that releases up
|
||||
to 3.1.0 wrote there count as unset and are removed on the next save. A value you set by hand is
|
||||
kept.
|
||||
- The About dialog shows the release version, and its debug info shows the identity that is
|
||||
actually sent.
|
||||
|
||||
## [3.1.0] — 2026-10-07
|
||||
|
||||
Schema: **42**, unchanged.
|
||||
@@ -1635,6 +1804,19 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
|
||||
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
||||
by a new POSIX compliance suite for the filesystem.
|
||||
|
||||
[3.4.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.4.1
|
||||
[3.4.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.4.0
|
||||
[3.3.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.3.0
|
||||
[3.2.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.2.1
|
||||
|
||||
[3.2.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.2.0
|
||||
|
||||
[3.1.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.2
|
||||
|
||||
[3.1.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.1
|
||||
|
||||
[3.1.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.0
|
||||
|
||||
[3.0.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.0.0
|
||||
|
||||
[2.10.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.10.0
|
||||
|
||||
+4
-3
@@ -65,9 +65,9 @@ commands are for scripts and for cases where the mount is not available.
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `pdfs pin PATH` | Download a file completely and keep it on this computer. |
|
||||
| `pdfs unpin PATH` | Release it back to online only. |
|
||||
| `pdfs pins` | List files kept available offline. |
|
||||
| `pdfs pin PATH` | Download a file completely and keep it on this computer. For a folder, its files download in the background (`pdfs transfers` shows the progress). |
|
||||
| `pdfs unpin PATH` | Release it back to online only. A file inside a folder kept offline cannot be released on its own. |
|
||||
| `pdfs pins` | List files and folders kept available offline. |
|
||||
|
||||
## Synced folders
|
||||
|
||||
@@ -103,6 +103,7 @@ commands are for scripts and for cases where the mount is not available.
|
||||
| `pdfs devices rename UID NAME` | Rename a computer. |
|
||||
| `pdfs devices rm UID` | Remove a computer and its backup from the account. |
|
||||
| `pdfs devices adopt UID` | Make this machine continue another computer's backup, for example after a reinstall. `--clear` returns to matching by hostname. |
|
||||
| `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). |
|
||||
|
||||
## Trash
|
||||
|
||||
|
||||
@@ -89,7 +89,11 @@ fix it.
|
||||
| `open_with` | object | `xdg-open` | — | See [`open_with`](#open_with) |
|
||||
| `prompt` | object | built-in window | — | See [`prompt`](#prompt) |
|
||||
|
||||
`app_version` and `user_agent` identify the client to Proton. Leave them alone.
|
||||
`app_version` and `user_agent` identify the client to Proton. When the keys are absent, the
|
||||
client sends its own release (`external-drive-linux@<version>-stable`, `proton-drive-linux/<version>`).
|
||||
Leave them out unless a support case asks for a different value. The fixed
|
||||
`external-drive-linux@0.1.0-alpha` and `proton-drive-linux/0.1.0` that releases up to 3.1.0 wrote
|
||||
here are treated as absent and removed on the next save.
|
||||
|
||||
### `ignore_patterns`
|
||||
|
||||
|
||||
+7
-3
@@ -153,15 +153,19 @@ Releases are cut from `main` by pushing a `v*` tag. `.github/workflows/release.y
|
||||
1. checks that the tag, the workspace version in `Cargo.toml` and `pkgver` in
|
||||
`packaging/PKGBUILD` agree;
|
||||
2. runs the quality gates;
|
||||
3. builds the release binaries and packages a `.tar.gz`, a `.deb` and a GPG-signed `.rpm`;
|
||||
4. publishes them as a GitHub release;
|
||||
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`);
|
||||
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`.
|
||||
|
||||
To prepare a release:
|
||||
|
||||
```bash
|
||||
# 1. Bump the version in Cargo.toml and packaging/PKGBUILD, then refresh the lock file.
|
||||
# 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.
|
||||
cargo update --workspace
|
||||
# 2. Move the [Unreleased] notes in docs/CHANGELOG.md under the new version.
|
||||
# 3. Commit, tag and push.
|
||||
|
||||
+71
-2
@@ -82,6 +82,68 @@ paru -S proton-drive-for-linux # or yay, or any other AUR helper
|
||||
|
||||
The recipes live in [`packaging/aur/`](../packaging/aur/README.md).
|
||||
|
||||
### 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:
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux";
|
||||
|
||||
outputs = { nixpkgs, proton-drive-linux, ... }: {
|
||||
nixosConfigurations.<host> = nixpkgs.lib.nixosSystem {
|
||||
modules = [
|
||||
proton-drive-linux.nixosModules.default
|
||||
{ services.proton-drive-linux.enable = true; }
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
That URL follows `main`. To stay on releases, pin the input to a release tag instead, and change
|
||||
the tag to update:
|
||||
|
||||
```nix
|
||||
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.3.0";
|
||||
```
|
||||
|
||||
Each release tag is built with Nix before the release is published.
|
||||
|
||||
#### Binary cache
|
||||
|
||||
CI uploads every build of `main` and of each release tag to a Cachix binary cache, so Nix can
|
||||
download the client instead of compiling it. A system build does not read a flake input's cache
|
||||
settings, so add the cache to your NixOS configuration once:
|
||||
|
||||
```nix
|
||||
nix.settings = {
|
||||
substituters = [ "https://proton-drive-linux.cachix.org" ];
|
||||
trusted-public-keys = [
|
||||
"proton-drive-linux.cachix.org-1:w1+3pXVXyTlqkFGn3dWpUhlaJtyGhSm3X0mFwfa++AA="
|
||||
];
|
||||
};
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
To try it without changing your system, run the app 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
|
||||
[`packaging/nix/`](../packaging/nix/).
|
||||
|
||||
### Other distributions
|
||||
|
||||
Unpack the `.tar.gz` into a directory on your `PATH`, then install the service unit as shown in
|
||||
@@ -240,8 +302,15 @@ pdfs logout # removes the session from the
|
||||
```
|
||||
|
||||
Then remove the package (`sudo apt remove proton-drive-linux`, `sudo dnf remove
|
||||
proton-drive-linux`, or `sudo pacman -R proton-drive-for-linux`), and, if you want no trace left,
|
||||
the local data:
|
||||
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
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
| [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
|
||||
|
||||
@@ -133,6 +133,29 @@ pdfs sync restore --device UID
|
||||
The folders stay part of the other computer's backup and sync in both directions. This machine
|
||||
does not take over that computer's backup.
|
||||
|
||||
### Moving another computer's backup here
|
||||
|
||||
To switch computers for good, for example from a Windows PC to this Linux machine, move the old
|
||||
computer's folders into this computer's backup instead. Use **Move to This Computer…** on that
|
||||
computer's row, or:
|
||||
|
||||
```bash
|
||||
pdfs devices list # find the old computer's UID
|
||||
pdfs devices migrate UID
|
||||
```
|
||||
|
||||
Each folder moves on Proton Drive itself, so nothing is uploaded again and no storage is used
|
||||
twice. A folder whose name this computer already uses gets the old computer's name added, for
|
||||
example `Documents (DESKTOP-1)`. The restore picker then opens, as in
|
||||
[Step 3](#step-3-restore-the-folders), so you choose where each folder goes here.
|
||||
|
||||
The old computer stays in your account, without those folders. Unlike
|
||||
[adoption](#step-2-continue-the-old-computers-backup), this machine keeps its own name and backup.
|
||||
|
||||
> [!WARNING]
|
||||
> Stop Proton Drive on the old computer first. A client still running there sees its folders
|
||||
> disappear, and may upload them again as new.
|
||||
|
||||
## 3. How the restore works
|
||||
|
||||
This section is for the curious and for anyone debugging a restore.
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Security model
|
||||
|
||||
This page explains what protects your files and account when you use this client, what it keeps
|
||||
on your computer, and what it does not protect against. It is a summary for users. The details,
|
||||
with references to the code, are in the
|
||||
[threat model](ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext). To
|
||||
report a vulnerability, see [SECURITY.md](../SECURITY.md).
|
||||
|
||||
This is an unofficial hobby project, not a Proton product. It has had no independent security
|
||||
audit. The source is open under the MIT license, so you can read and build all of it yourself.
|
||||
|
||||
## Encryption
|
||||
|
||||
Proton Drive is end-to-end encrypted, and this client does not change that:
|
||||
|
||||
- **Files are encrypted on this computer before they are uploaded,** and decrypted on this
|
||||
computer after they are downloaded. Proton never receives your keys or the content of your
|
||||
files.
|
||||
- **The cryptography comes from the SDK, not from this client.** The client uses
|
||||
[`proton-sdk` and `proton-drive-rs`](https://github.com/narrrl/proton-sdk-rs), a Rust port of
|
||||
Proton's official C# and TypeScript Drive SDKs. OpenPGP is done by the
|
||||
[rPGP](https://github.com/rpgp/rpgp) library. The client itself does not implement any
|
||||
encryption.
|
||||
- **Your password is not sent to Proton.** Sign-in uses Proton's SRP protocol, which proves that
|
||||
you know the password without sending it.
|
||||
|
||||
## What is kept on this computer
|
||||
|
||||
| What | Where | Protected by |
|
||||
|---|---|---|
|
||||
| Session tokens and the key passphrases | The system keyring (Secret Service: GNOME Keyring, KWallet, KeePassXC) | The keyring's own encryption and your login |
|
||||
| Your password | Nowhere | — |
|
||||
| Cached files, thumbnails and changes waiting to upload | `~/.cache/proton-drive-linux/content/` | File modes only: **not encrypted** |
|
||||
| File and folder names, the folder tree, the activity log | `~/.local/state/proton-drive-linux/cache.db` | File modes only: **not encrypted** |
|
||||
| The SDK's metadata cache | `~/.local/state/proton-drive-linux/sdk_cache.db` | Encrypted with a key derived from your mailbox password |
|
||||
| Settings | `~/.config/proton-drive-linux/config.json` | No secrets are stored here |
|
||||
|
||||
The cache holds decrypted content so that files open fast and work offline. **The client assumes
|
||||
that your disk is encrypted** (LUKS, or an encrypted home directory). On an unencrypted disk,
|
||||
someone who has the powered-off computer can read the cached files and the names in your Drive.
|
||||
|
||||
Accounts with a separate mailbox password (two-password mode) are locked by default. Their key
|
||||
passphrases are kept in the kernel keyring for your login session only, and you unlock again
|
||||
after each login. Storing them in the system keyring is an opt-in.
|
||||
|
||||
## Who can use the client
|
||||
|
||||
- The state, cache and config directories must belong to you and have mode `0700`. The daemon
|
||||
checks this at every start and refuses to run otherwise.
|
||||
- The control socket that the app, tray and `pdfs` use has mode `0600`. Only your user account
|
||||
can connect to it.
|
||||
- Any program that runs as your user can read, change and delete files in `~/ProtonDrive`, the same
|
||||
as in any other folder you own. It is your Proton Drive, not a copy: a deleted file is moved to
|
||||
the Proton Drive trash, where you can restore it.
|
||||
|
||||
## Network connections
|
||||
|
||||
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.
|
||||
|
||||
There is no telemetry, no analytics and no update check. 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
|
||||
|
||||
- **Malware running as your user.** It can read the cache, connect to the control socket, and use
|
||||
your Drive through the mount, as it can with any data you have access to.
|
||||
- **Swap.** Keys and decrypted data are in ordinary memory and can be written to swap. Use
|
||||
encrypted swap.
|
||||
- **The kernel page cache.** File content read through the mount is cached by the kernel like any
|
||||
other file.
|
||||
|
||||
## Packages
|
||||
|
||||
- The `.rpm` is signed with GPG.
|
||||
- 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
|
||||
checked against their SHA-256 sums.
|
||||
- The Nix flake builds from source. The binary cache on Cachix holds builds made by CI.
|
||||
+31
-2
@@ -69,8 +69,31 @@ pdfs status
|
||||
## Signing in
|
||||
|
||||
**"Proton is asking for a CAPTCHA"** — Proton wants human verification, usually on a VPN or an
|
||||
unfamiliar network. The CLI cannot show it. Sign in with `pdfs-app`, which displays the challenge;
|
||||
the stored session then works for the CLI too.
|
||||
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.
|
||||
|
||||
**"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:
|
||||
|
||||
```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.
|
||||
|
||||
**"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.
|
||||
|
||||
**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
|
||||
@@ -90,6 +113,12 @@ 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** — check that the service runs and is signed in
|
||||
(`pdfs status`). The service waits for a stored session before it mounts.
|
||||
|
||||
|
||||
+17
-6
@@ -44,7 +44,7 @@ The app uses these words consistently, and so does this guide:
|
||||
| **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 |
|
||||
| **Available offline** | A single file kept on this computer |
|
||||
| **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`) |
|
||||
|
||||
@@ -75,11 +75,14 @@ try to create them get a clean error, not a hang or `EIO`.
|
||||
|
||||
## Keeping files on this computer
|
||||
|
||||
Turn on **Make available offline** for a file in My files, or run `pdfs pin <path>`. The file is
|
||||
downloaded completely and never evicted from the cache. `pdfs pins` lists these files, and
|
||||
**Preferences → Storage → Available offline** shows them in the app.
|
||||
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 file stays on Proton Drive.
|
||||
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
|
||||
|
||||
@@ -96,7 +99,9 @@ app and in the web app.
|
||||
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`. `pdfs locations` lists every local place the service occupies.
|
||||
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
|
||||
|
||||
@@ -146,6 +151,9 @@ places, and both apply:
|
||||
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
|
||||
@@ -278,6 +286,9 @@ Proton Drive keeps earlier versions of every file. **Details → Versions…** i
|
||||
**Restore to This Computer…** downloads its folders here.
|
||||
- **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.
|
||||
|
||||
## Search launcher
|
||||
|
||||
|
||||
Generated
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1791456040,
|
||||
"narHash": "sha256-H83yz/do+hjOO0uujHyH8RgtIQP2v7Mw+cLwcZK/xKc=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e7439b6b14ad3cc35d05608ebca9bce01a25f5f8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
description = "Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app and tray";
|
||||
|
||||
# CI pushes every build of `main` and of each release tag here, so installing
|
||||
# downloads the client instead of compiling it. Nix asks before it uses a
|
||||
# flake's caches unless the user trusts them (docs/INSTALL.md).
|
||||
nixConfig = {
|
||||
extra-substituters = [ "https://proton-drive-linux.cachix.org" ];
|
||||
extra-trusted-public-keys = [
|
||||
"proton-drive-linux.cachix.org-1:w1+3pXVXyTlqkFGn3dWpUhlaJtyGhSm3X0mFwfa++AA="
|
||||
];
|
||||
};
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
outputs =
|
||||
{ self, nixpkgs }:
|
||||
let
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
];
|
||||
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||
in
|
||||
{
|
||||
packages = forAllSystems (pkgs: rec {
|
||||
proton-drive-linux = pkgs.callPackage ./packaging/nix/package.nix { };
|
||||
default = proton-drive-linux;
|
||||
});
|
||||
|
||||
overlays.default = final: _: {
|
||||
proton-drive-linux = final.callPackage ./packaging/nix/package.nix { };
|
||||
};
|
||||
|
||||
nixosModules.default = import ./packaging/nix/module.nix self;
|
||||
|
||||
devShells = forAllSystems (pkgs: {
|
||||
default = pkgs.mkShell {
|
||||
inputsFrom = [ self.packages.${pkgs.stdenv.hostPlatform.system}.proton-drive-linux ];
|
||||
packages = with pkgs; [
|
||||
cargo
|
||||
clippy
|
||||
rustfmt
|
||||
rust-analyzer
|
||||
];
|
||||
};
|
||||
});
|
||||
|
||||
formatter = forAllSystems (pkgs: pkgs.nixfmt);
|
||||
};
|
||||
}
|
||||
+3
-1
@@ -9,7 +9,7 @@
|
||||
# Build + install: cd packaging && makepkg -fi
|
||||
|
||||
pkgname=proton-drive-linux
|
||||
pkgver=3.1.0
|
||||
pkgver=3.4.1
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
||||
arch=('x86_64')
|
||||
@@ -26,6 +26,8 @@ optdepends=('gnome-keyring: credential storage via Secret Service'
|
||||
'fzf: live search-as-you-type launcher (pdfs-prompt --fzf)'
|
||||
'foot: terminal for pdfs-prompt --fzf started from a keybinding')
|
||||
options=('!lto' '!debug')
|
||||
# Stops the running daemon before removal, so the mount stops mirroring deletes.
|
||||
install=proton-drive.install
|
||||
|
||||
# Built in place from the checkout; nothing to fetch or checksum.
|
||||
source=()
|
||||
|
||||
@@ -13,7 +13,8 @@ The three packages conflict with each other and with the in-tree
|
||||
`packaging/PKGBUILD` package (`proton-drive-linux`), and every one of them
|
||||
`provides` `proton-drive-for-linux`, `proton-drive-linux`, and `pdfs`.
|
||||
|
||||
Each directory tracks only `PKGBUILD`, `.SRCINFO`, `.gitignore`, and `LICENSE`
|
||||
Each directory tracks only `PKGBUILD`, `.SRCINFO`, `.gitignore`, `LICENSE` and
|
||||
`proton-drive.install` (a link to `packaging/proton-drive.install`, copied as a file on push)
|
||||
(the 0BSD license of the packaging sources, per
|
||||
[RFC40](https://rfc.archlinux.page/0040-license-package-sources/)); the
|
||||
`.gitignore` ignores everything else so downloaded sources, `src/`, `pkg/`, and
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
pkgbase = proton-drive-for-linux-bin
|
||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
||||
pkgver = 3.0.0
|
||||
pkgver = 3.4.0
|
||||
pkgrel = 1
|
||||
url = https://proton-drive.narl.io
|
||||
install = proton-drive.install
|
||||
arch = x86_64
|
||||
license = MIT
|
||||
depends = fuse3
|
||||
@@ -21,21 +22,21 @@ 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.0.0
|
||||
provides = proton-drive-linux=3.0.0
|
||||
provides = proton-drive-for-linux=3.4.0
|
||||
provides = proton-drive-linux=3.4.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.0.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.0.0/proton-drive-linux-3.0.0-x86_64.tar.gz
|
||||
source = io.narl.proton-drive-linux-3.0.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/packaging/io.narl.proton-drive-linux.desktop
|
||||
source = io.narl.proton-drive-linux-tray-3.0.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/packaging/io.narl.proton-drive-linux-tray.desktop
|
||||
source = io.narl.proton-drive-linux-3.0.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/packaging/io.narl.proton-drive-linux.svg
|
||||
source = proton-drive-3.0.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/packaging/proton-drive.service
|
||||
source = LICENSE-3.0.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/LICENSE
|
||||
sha256sums = 4d72420f287e0a9b1d65dfc2954779b2df1bf367cb41ffe28519b2791451b6ed
|
||||
source = proton-drive-linux-3.4.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.4.0/proton-drive-linux-3.4.0-x86_64.tar.gz
|
||||
source = io.narl.proton-drive-linux-3.4.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.4.0/packaging/io.narl.proton-drive-linux.desktop
|
||||
source = io.narl.proton-drive-linux-tray-3.4.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.4.0/packaging/io.narl.proton-drive-linux-tray.desktop
|
||||
source = io.narl.proton-drive-linux-3.4.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.4.0/packaging/io.narl.proton-drive-linux.svg
|
||||
source = proton-drive-3.4.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.4.0/packaging/proton-drive.service
|
||||
source = LICENSE-3.4.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.4.0/LICENSE
|
||||
sha256sums = bc154c408d572d45847bb9cfdb0e00b94f6d3da6fa484832d8192512de1e2f12
|
||||
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
||||
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
||||
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
!.SRCINFO
|
||||
!LICENSE
|
||||
!PKGBUILD
|
||||
!proton-drive.install
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||
|
||||
pkgname=proton-drive-for-linux-bin
|
||||
pkgver=3.0.0
|
||||
pkgver=3.4.0
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
||||
arch=('x86_64')
|
||||
@@ -22,6 +22,8 @@ provides=("${pkgname%-bin}=$pkgver" "proton-drive-linux=$pkgver" 'pdfs')
|
||||
conflicts=('proton-drive-linux' 'proton-drive-for-linux' 'proton-drive-for-linux-git')
|
||||
# Upstream ships the binaries already built on Ubuntu 24.04; do not rewrite them.
|
||||
options=('!strip' '!debug')
|
||||
# Stops the running daemon before removal, so the mount stops mirroring deletes.
|
||||
install=proton-drive.install
|
||||
_tag="v$pkgver"
|
||||
_raw="https://raw.githubusercontent.com/narrrl/proton-drive-linux/$_tag"
|
||||
# The release tarball holds the four binaries only, so the desktop integration
|
||||
@@ -34,7 +36,7 @@ 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=('4d72420f287e0a9b1d65dfc2954779b2df1bf367cb41ffe28519b2791451b6ed'
|
||||
sha256sums=('bc154c408d572d45847bb9cfdb0e00b94f6d3da6fa484832d8192512de1e2f12'
|
||||
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
||||
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
||||
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
../../proton-drive.install
|
||||
@@ -1,8 +1,9 @@
|
||||
pkgbase = proton-drive-for-linux-git
|
||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
|
||||
pkgver = 3.0.0.r0.g2177383
|
||||
pkgver = 3.4.0.r0.gb46abfc
|
||||
pkgrel = 1
|
||||
url = https://proton-drive.narl.io
|
||||
install = proton-drive.install
|
||||
arch = x86_64
|
||||
license = MIT
|
||||
makedepends = cargo
|
||||
@@ -24,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.0.0.r0.g2177383
|
||||
provides = proton-drive-linux=3.0.0.r0.g2177383
|
||||
provides = proton-drive-for-linux=3.4.0.r0.gb46abfc
|
||||
provides = proton-drive-linux=3.4.0.r0.gb46abfc
|
||||
provides = pdfs
|
||||
conflicts = proton-drive-linux
|
||||
conflicts = proton-drive-for-linux
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
!.SRCINFO
|
||||
!LICENSE
|
||||
!PKGBUILD
|
||||
!proton-drive.install
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||
|
||||
pkgname=proton-drive-for-linux-git
|
||||
pkgver=3.0.0.r0.g2177383
|
||||
pkgver=3.4.0.r0.gb46abfc
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
||||
arch=('x86_64')
|
||||
@@ -23,6 +23,8 @@ provides=("${pkgname%-git}=$pkgver" "proton-drive-linux=$pkgver" 'pdfs')
|
||||
conflicts=('proton-drive-linux' 'proton-drive-for-linux' 'proton-drive-for-linux-bin')
|
||||
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
|
||||
options=('!lto')
|
||||
# Stops the running daemon before removal, so the mount stops mirroring deletes.
|
||||
install=proton-drive.install
|
||||
source=("$pkgname::git+$_repo.git")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
../../proton-drive.install
|
||||
@@ -1,8 +1,9 @@
|
||||
pkgbase = proton-drive-for-linux
|
||||
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
||||
pkgver = 3.0.0
|
||||
pkgver = 3.4.0
|
||||
pkgrel = 1
|
||||
url = https://proton-drive.narl.io
|
||||
install = proton-drive.install
|
||||
arch = x86_64
|
||||
license = MIT
|
||||
makedepends = cargo
|
||||
@@ -23,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.0.0
|
||||
provides = proton-drive-linux=3.4.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.0.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.0.0.tar.gz
|
||||
sha256sums = 3a9ca82adae6b03e2b33af520ec49f702a3b4c83151ccc70ae3c21645b719ab5
|
||||
source = proton-drive-for-linux-3.4.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.4.0.tar.gz
|
||||
sha256sums = 1c507afac332d6e89de0c9fe6f37f3a236c31c9e498e3032604ac963f52c1827
|
||||
|
||||
pkgname = proton-drive-for-linux
|
||||
@@ -5,3 +5,4 @@
|
||||
!.SRCINFO
|
||||
!LICENSE
|
||||
!PKGBUILD
|
||||
!proton-drive.install
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||
|
||||
pkgname=proton-drive-for-linux
|
||||
pkgver=3.0.0
|
||||
pkgver=3.4.0
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
||||
arch=('x86_64')
|
||||
@@ -23,8 +23,10 @@ provides=("proton-drive-linux=$pkgver" 'pdfs')
|
||||
conflicts=('proton-drive-linux' 'proton-drive-for-linux-bin' 'proton-drive-for-linux-git')
|
||||
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
|
||||
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=('3a9ca82adae6b03e2b33af520ec49f702a3b4c83151ccc70ae3c21645b719ab5')
|
||||
sha256sums=('1c507afac332d6e89de0c9fe6f37f3a236c31c9e498e3032604ac963f52c1827')
|
||||
|
||||
_srcname="proton-drive-linux-$pkgver"
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
../../proton-drive.install
|
||||
@@ -21,9 +21,11 @@ for pkg in "${packages[@]}"; do
|
||||
# A fresh clone every time: the AUR repository, not this checkout, is what the
|
||||
# commit history has to build on.
|
||||
git -c init.defaultBranch=master clone -q "ssh://aur@aur.archlinux.org/$pkg.git" "$workdir/$pkg"
|
||||
install -m644 "$pkg/PKGBUILD" "$pkg/.SRCINFO" "$pkg/.gitignore" "$pkg/LICENSE" "$workdir/$pkg/"
|
||||
# proton-drive.install is a link into packaging/; install copies its content.
|
||||
install -m644 "$pkg/PKGBUILD" "$pkg/.SRCINFO" "$pkg/.gitignore" "$pkg/LICENSE" \
|
||||
"$pkg/proton-drive.install" "$workdir/$pkg/"
|
||||
|
||||
git -C "$workdir/$pkg" add -f PKGBUILD .SRCINFO .gitignore LICENSE
|
||||
git -C "$workdir/$pkg" add -f PKGBUILD .SRCINFO .gitignore LICENSE proton-drive.install
|
||||
if git -C "$workdir/$pkg" diff --cached --quiet; then
|
||||
echo "$pkg: no changes"
|
||||
continue
|
||||
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
# 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,
|
||||
# `rm -rf ~/ProtonDrive` included, are still deleted on Proton Drive. Stop every
|
||||
# logged-in user's service first; anything still queued stays on disk for the
|
||||
# next install. Same loop as packaging/proton-drive.install.
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
remove) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
for dir in /run/user/*; do
|
||||
user=$(id -nu "${dir##*/}" 2>/dev/null) || continue
|
||||
if runuser -u "$user" -- env XDG_RUNTIME_DIR="$dir" \
|
||||
systemctl --user --quiet is-active proton-drive.service 2>/dev/null; then
|
||||
echo "Stopping Proton Drive for $user, which unmounts its drive."
|
||||
runuser -u "$user" -- env XDG_RUNTIME_DIR="$dir" \
|
||||
systemctl --user stop proton-drive.service 2>/dev/null || :
|
||||
fi
|
||||
done
|
||||
@@ -0,0 +1,42 @@
|
||||
# NixOS module: installs the package and runs the mount daemon in every
|
||||
# graphical session, as the other packages' user service does.
|
||||
#
|
||||
# services.proton-drive-linux.enable = true;
|
||||
self:
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
cfg = config.services.proton-drive-linux;
|
||||
in
|
||||
{
|
||||
options.services.proton-drive-linux = {
|
||||
enable = lib.mkEnableOption "the Proton Drive for Linux mount daemon, app and tray";
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = self.packages.${pkgs.stdenv.hostPlatform.system}.proton-drive-linux;
|
||||
defaultText = lib.literalExpression "proton-drive-linux.packages.\${system}.proton-drive-linux";
|
||||
description = "The package to use.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
# The unit ships in the package; this links it and pulls it into the
|
||||
# graphical session, which is what `systemctl --user enable` would do.
|
||||
systemd.packages = [ cfg.package ];
|
||||
systemd.user.services.proton-drive = {
|
||||
wantedBy = [ "graphical-session.target" ];
|
||||
# The mount runs fusermount3 from PATH, and it has to be the setuid
|
||||
# wrapper; the PATH NixOS gives a unit does not include it.
|
||||
path = [ "/run/wrappers" ];
|
||||
};
|
||||
programs.fuse.enable = lib.mkDefault true;
|
||||
# Session tokens are kept in the Secret Service.
|
||||
services.gnome.gnome-keyring.enable = lib.mkDefault true;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
# Nix package for the workspace, built from the checkout this file lives in.
|
||||
# The flake at the repository root exposes it; `nix build` builds it.
|
||||
{
|
||||
lib,
|
||||
rustPlatform,
|
||||
adwaita-icon-theme,
|
||||
pkg-config,
|
||||
gettext,
|
||||
glib,
|
||||
wrapGAppsHook4,
|
||||
fuse3,
|
||||
glib-networking,
|
||||
gtk4,
|
||||
libadwaita,
|
||||
webkitgtk_6_0,
|
||||
libsecret,
|
||||
dbus,
|
||||
exiftool,
|
||||
ffmpeg-headless,
|
||||
withFfmpeg ? true,
|
||||
}:
|
||||
|
||||
let
|
||||
cargoToml = lib.importTOML ../../Cargo.toml;
|
||||
in
|
||||
rustPlatform.buildRustPackage {
|
||||
pname = "proton-drive-linux";
|
||||
inherit (cargoToml.workspace.package) version;
|
||||
|
||||
# Only what the build reads, so editing docs or CI does not rebuild.
|
||||
src = lib.fileset.toSource {
|
||||
root = ../..;
|
||||
fileset = lib.fileset.unions [
|
||||
../../Cargo.toml
|
||||
../../Cargo.lock
|
||||
../../crates
|
||||
../../po
|
||||
../../LICENSE
|
||||
../proton-drive.service
|
||||
../io.narl.proton-drive-linux.desktop
|
||||
../io.narl.proton-drive-linux-tray.desktop
|
||||
../io.narl.proton-drive-linux.svg
|
||||
];
|
||||
};
|
||||
|
||||
cargoLock.lockFile = ../../Cargo.lock;
|
||||
|
||||
cargoBuildFlags = [
|
||||
"--bin"
|
||||
"pdfs"
|
||||
"--bin"
|
||||
"pdfs-tray"
|
||||
"--bin"
|
||||
"pdfs-app"
|
||||
"--bin"
|
||||
"pdfs-prompt"
|
||||
];
|
||||
|
||||
nativeBuildInputs = [
|
||||
pkg-config
|
||||
gettext # msgfmt, for po/build.sh
|
||||
glib # glib-compile-resources, for the GUI's build.rs
|
||||
wrapGAppsHook4
|
||||
];
|
||||
|
||||
buildInputs = [
|
||||
fuse3
|
||||
# TLS for WebKitGTK's GIO: without it the CAPTCHA page fails with "TLS
|
||||
# support is not available". wrapGAppsHook4 adds it to GIO_EXTRA_MODULES.
|
||||
glib-networking
|
||||
# The stock icons the app asks for, such as the Photos and Computers
|
||||
# sidebar entries. GTK ships only a few itself, and a session without
|
||||
# Adwaita installed showed the rest as missing. wrapGAppsHook4 adds it to
|
||||
# XDG_DATA_DIRS.
|
||||
adwaita-icon-theme
|
||||
gtk4
|
||||
libadwaita
|
||||
webkitgtk_6_0
|
||||
libsecret
|
||||
dbus
|
||||
];
|
||||
|
||||
# The app looks for its catalogs here instead of /usr/share/locale.
|
||||
env.PDFS_LOCALEDIR = "${placeholder "out"}/share/locale";
|
||||
|
||||
# The suite mounts FUSE, talks to D-Bus and the kernel keyring, and checks
|
||||
# permissions a build sandbox does not have; CI runs it on every push.
|
||||
doCheck = false;
|
||||
|
||||
postInstall = ''
|
||||
install -Dm644 packaging/io.narl.proton-drive-linux.desktop \
|
||||
$out/share/applications/io.narl.proton-drive-linux.desktop
|
||||
install -Dm644 packaging/io.narl.proton-drive-linux-tray.desktop \
|
||||
$out/etc/xdg/autostart/io.narl.proton-drive-linux-tray.desktop
|
||||
install -Dm644 packaging/io.narl.proton-drive-linux.svg \
|
||||
$out/share/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg
|
||||
substituteInPlace $out/share/applications/io.narl.proton-drive-linux.desktop \
|
||||
--replace-fail "Exec=pdfs-app" "Exec=$out/bin/pdfs-app"
|
||||
substituteInPlace $out/etc/xdg/autostart/io.narl.proton-drive-linux-tray.desktop \
|
||||
--replace-fail "Exec=pdfs-tray" "Exec=$out/bin/pdfs-tray"
|
||||
|
||||
install -Dm644 packaging/proton-drive.service \
|
||||
$out/lib/systemd/user/proton-drive.service
|
||||
substituteInPlace $out/lib/systemd/user/proton-drive.service \
|
||||
--replace-fail "ExecStart=/usr/bin/pdfs" "ExecStart=$out/bin/pdfs"
|
||||
|
||||
patchShebangs po/build.sh
|
||||
po/build.sh $out/share/locale
|
||||
'';
|
||||
|
||||
# Thumbnails shell out to exiftool, and to ffmpeg for video. Suffixed, so a
|
||||
# user's own builds on PATH win. fusermount3 comes from the system: on NixOS
|
||||
# it is the setuid wrapper in /run/wrappers/bin.
|
||||
preFixup = ''
|
||||
gappsWrapperArgs+=(--suffix PATH : ${
|
||||
lib.makeBinPath ([ exiftool ] ++ lib.optional withFfmpeg ffmpeg-headless)
|
||||
})
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app and tray";
|
||||
homepage = "https://proton-drive.narl.io";
|
||||
license = lib.licenses.mit;
|
||||
platforms = lib.platforms.linux;
|
||||
mainProgram = "pdfs";
|
||||
};
|
||||
}
|
||||
@@ -37,11 +37,13 @@ Requires: webkitgtk6.0
|
||||
Requires: perl-Image-ExifTool
|
||||
Requires: xdg-utils
|
||||
|
||||
# DE-specific; do not Require a single desktop environment.
|
||||
Recommends: gnome-keyring
|
||||
# DE-specific; do not Require a single desktop environment. dnf installs weak
|
||||
# dependencies by default, so each is conditional on its own desktop: a plain
|
||||
# `Recommends: kwallet` pulled KDE Frameworks onto GNOME installs.
|
||||
Recommends: (gnome-keyring or plasma-workspace)
|
||||
Recommends: iso-codes
|
||||
Recommends: gnome-shell-extension-appindicator
|
||||
Recommends: kwallet
|
||||
Recommends: (gnome-shell-extension-appindicator if gnome-shell)
|
||||
Recommends: (kwallet if plasma-workspace)
|
||||
|
||||
# Only for `pdfs-prompt --fzf`; every other front end works without them.
|
||||
Suggests: fzf
|
||||
@@ -90,6 +92,25 @@ install -D -m0644 %{git_dir}/packaging/proton-drive.service \
|
||||
%{buildroot}/usr/lib/systemd/user/proton-drive.service
|
||||
%{git_dir}/po/build.sh %{buildroot}%{_datadir}/locale
|
||||
|
||||
# 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,
|
||||
# `rm -rf ~/ProtonDrive` included, are still deleted on Proton Drive. Stop every
|
||||
# logged-in user's service first; anything still queued stays on disk for the
|
||||
# next install. $1 is 0 on an erase and 1 on an upgrade. Same loop as
|
||||
# packaging/proton-drive.install.
|
||||
%preun
|
||||
if [ "$1" -eq 0 ]; then
|
||||
for dir in /run/user/*; do
|
||||
user=$(id -nu "${dir##*/}" 2>/dev/null) || continue
|
||||
if runuser -u "$user" -- env XDG_RUNTIME_DIR="$dir" \
|
||||
systemctl --user --quiet is-active proton-drive.service 2>/dev/null; then
|
||||
echo "Stopping Proton Drive for $user, which unmounts its drive."
|
||||
runuser -u "$user" -- env XDG_RUNTIME_DIR="$dir" \
|
||||
systemctl --user stop proton-drive.service 2>/dev/null || :
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
%files
|
||||
%license LICENSE
|
||||
%{_bindir}/pdfs
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# pacman install script, shared by packaging/PKGBUILD and the three AUR packages
|
||||
# (each links it). The same loop is in packaging/deb/prerm and the RPM %preun.
|
||||
|
||||
# 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,
|
||||
# `rm -rf ~/ProtonDrive` included, are still deleted on Proton Drive. Stop every
|
||||
# logged-in user's service first; anything still queued stays on disk for the
|
||||
# next install. Upgrades call pre_upgrade, not this.
|
||||
pre_remove() {
|
||||
for dir in /run/user/*; do
|
||||
user=$(id -nu "${dir##*/}" 2>/dev/null) || continue
|
||||
if runuser -u "$user" -- env XDG_RUNTIME_DIR="$dir" \
|
||||
systemctl --user --quiet is-active proton-drive.service 2>/dev/null; then
|
||||
echo "Stopping Proton Drive for $user, which unmounts its drive."
|
||||
runuser -u "$user" -- env XDG_RUNTIME_DIR="$dir" \
|
||||
systemctl --user stop proton-drive.service 2>/dev/null || :
|
||||
fi
|
||||
done
|
||||
}
|
||||
+1024
-844
File diff suppressed because it is too large.
Load diff
+1007
-863
File diff suppressed because it is too large.
Load diff
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user