mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
b8cfd30ff3 | ||
|
|
9fa88ff397 | ||
|
|
f105159186 | ||
|
|
a14a63c94c | ||
|
|
ed3477d1b3 | ||
|
|
aa384ae2eb | ||
|
|
10c69b0d64 | ||
|
|
39be354bd2 | ||
|
|
2cca8afba9 | ||
|
|
6e9beb4b16 | ||
|
|
63484bf4b6 | ||
|
|
37c66b7220 | ||
|
|
41b6edbecb |
No files matched your search
@@ -64,7 +64,7 @@ jobs:
|
||||
# Real Proton credentials and /dev/fuse deliberately do not run on pull
|
||||
# requests. Follow docs/TESTING.md on a dedicated test-account runner.
|
||||
|
||||
# The seeded simulation runs (docs/MILESTONE-3.0.0.md §8.2): real FUSE
|
||||
# The seeded simulation runs (docs/TESTING.md): real FUSE
|
||||
# daemons against an in-memory Drive, so they need /dev/fuse but no account.
|
||||
simulation:
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
name: Test build
|
||||
|
||||
# On-demand Fedora RPM of any branch, for someone to try a fix before it ships.
|
||||
# Run it from the Actions tab ("Run workflow", pick the branch). Built inside a
|
||||
# Fedora container so the binaries link against Fedora's own GTK, libadwaita and
|
||||
# WebKitGTK, not the Ubuntu ones the release RPM is built against.
|
||||
#
|
||||
# The RPM is unsigned and versioned `<Cargo version>-1.test.<sha>`, so it sorts
|
||||
# below the release it precedes and `dnf` replaces it when that ships.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
fedora:
|
||||
description: 'Fedora release to build on'
|
||||
required: false
|
||||
default: '44'
|
||||
type: string
|
||||
prerelease:
|
||||
description: 'Also publish a pre-release anyone can download without a GitHub login'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: test-build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
rpm:
|
||||
name: Fedora ${{ inputs.fedora }} RPM
|
||||
runs-on: ubuntu-24.04
|
||||
container: fedora:${{ inputs.fedora }}
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
dnf install -y --setopt=install_weak_deps=False \
|
||||
git rpm-build gcc gcc-c++ make gettext curl \
|
||||
pkgconf-pkg-config fuse3-devel gtk4-devel libadwaita-devel \
|
||||
libsecret-devel dbus-devel glib2-devel webkitgtk6.0-devel
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# Fedora's packaged Rust can trail the workspace's minimum (1.96).
|
||||
- name: Set up Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: '1.96'
|
||||
cache: false
|
||||
|
||||
- name: Cache Cargo target
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: fedora-test-build
|
||||
save-if: false
|
||||
|
||||
- name: Build release binaries
|
||||
run: cargo build --release --locked
|
||||
|
||||
- name: Package RPM
|
||||
run: |
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
VERSION=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2)
|
||||
SHORT=${GITHUB_SHA::7}
|
||||
mkdir -p rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS,BUILDROOT,db}
|
||||
rpmbuild -bb \
|
||||
--nodeps \
|
||||
--define "_topdir $PWD/rpmbuild" \
|
||||
--define "_sourcedir $PWD" \
|
||||
--define "git_dir $PWD" \
|
||||
--define "prebuilt 1" \
|
||||
--define "version ${VERSION}" \
|
||||
--define "dist .test.${SHORT}" \
|
||||
--define "_dbpath $PWD/rpmbuild/db" \
|
||||
packaging/proton-drive-linux.spec
|
||||
mkdir -p out
|
||||
cp rpmbuild/RPMS/x86_64/*.rpm out/
|
||||
# The bare binaries, for trying one without installing anything.
|
||||
tar -czf "out/proton-drive-linux-${VERSION}-${SHORT}-fedora${{ inputs.fedora }}-x86_64.tar.gz" \
|
||||
-C target/release pdfs pdfs-tray pdfs-app pdfs-prompt
|
||||
ls -l out
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: proton-drive-linux-fedora${{ inputs.fedora }}-${{ github.sha }}
|
||||
path: out/*
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
prerelease:
|
||||
name: Publish pre-release
|
||||
needs: rpm
|
||||
if: inputs.prerelease
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download build
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: out
|
||||
merge-multiple: true
|
||||
|
||||
# One rolling pre-release per branch: a new run replaces the last one, so
|
||||
# the link a tester was given keeps pointing at the newest build.
|
||||
- name: Publish
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
TAG="test-$(echo "${GITHUB_REF_NAME}" | tr -c 'A-Za-z0-9._\n' '-')"
|
||||
gh release delete "$TAG" --cleanup-tag --yes 2>/dev/null || true
|
||||
gh release create "$TAG" out/* \
|
||||
--target "$GITHUB_SHA" \
|
||||
--prerelease \
|
||||
--title "Test build of ${GITHUB_REF_NAME} (${GITHUB_SHA::7})" \
|
||||
--notes "Unsigned test build of branch \`${GITHUB_REF_NAME}\` at ${GITHUB_SHA}. Not a release; replaced by the next test build of this branch."
|
||||
+3
-3
@@ -39,9 +39,9 @@ cargo test --workspace --locked
|
||||
scripts/fuse-acceptance.sh --offline-only
|
||||
```
|
||||
|
||||
Changes to the FUSE layer, sync, or the pending-operation queue should also be exercised against a
|
||||
real mount before review — see [`docs/TESTING.md`](docs/TESTING.md). Live runs are destructive:
|
||||
use a dedicated test account, never one holding data you cannot lose.
|
||||
Changes to the FUSE layer, sync, or the queue should also pass the simulation runs and be
|
||||
exercised against a real mount before review — see [`docs/TESTING.md`](docs/TESTING.md). Live
|
||||
runs are destructive: use a dedicated test account, never one holding data you cannot lose.
|
||||
|
||||
## Making changes
|
||||
|
||||
|
||||
Generated
+14
-10
@@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-cli"
|
||||
version = "3.0.0"
|
||||
version = "3.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3485,13 +3485,15 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-core"
|
||||
version = "3.0.0"
|
||||
version = "3.2.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
"dbus-secret-service-keyring-store",
|
||||
"directories",
|
||||
"ignore",
|
||||
@@ -3508,12 +3510,13 @@ dependencies = [
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"zeroize",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-fuse"
|
||||
version = "3.0.0"
|
||||
version = "3.2.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -3535,7 +3538,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-gui"
|
||||
version = "3.0.0"
|
||||
version = "3.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3572,9 +3575,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pgp"
|
||||
version = "0.20.0"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1"
|
||||
checksum = "ae70f4d9325a391db30d115d6191a7bb67cec856aa0bba131290f0c4e09a532a"
|
||||
dependencies = [
|
||||
"aead 0.5.2",
|
||||
"aes 0.8.4",
|
||||
@@ -3775,9 +3778,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-drive-rs"
|
||||
version = "0.7.2"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6ce113d234474821d34787436ee80e1458e3d8a8f97212219615ed315eb70be"
|
||||
checksum = "1e789cab811c5e73e47fd97810b78a513cb254f4dd763b4eb3cad680d6dd31da"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -3802,9 +3805,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-sdk"
|
||||
version = "0.7.2"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a33c67df9694f90ce60e5b34984275956244138511b4640ca0d100e1dac8b3d"
|
||||
checksum = "ace260acbf88c26f5bd4aef8c8c9898c322163c56620bdef3b6dd7742bec7619"
|
||||
dependencies = [
|
||||
"aes-gcm 0.11.1",
|
||||
"async-trait",
|
||||
@@ -3831,6 +3834,7 @@ dependencies = [
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+4
-3
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "3.0.0"
|
||||
version = "3.2.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
||||
categories = ["filesystem", "command-line-utilities"]
|
||||
|
||||
[workspace.dependencies]
|
||||
proton-sdk = { version = "0.7.2" }
|
||||
proton-drive-rs = { version = "0.7.2" }
|
||||
proton-sdk = { version = "0.8.2" }
|
||||
proton-drive-rs = { version = "0.8.2" }
|
||||
|
||||
# Internal crates.
|
||||
pdfs-core = { path = "crates/pdfs-core" }
|
||||
@@ -26,6 +26,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs",
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.23"
|
||||
zeroize = "1"
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
# Non-poisoning mutexes. A handler that panics while holding a lock must not
|
||||
|
||||
@@ -15,6 +15,7 @@ scriptable CLI.
|
||||
[](https://aur.archlinux.org/packages/proton-drive-for-linux)
|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org)
|
||||
[](https://buymeacoffee.com/narl)
|
||||
|
||||
<img src="images/files.png" alt="My files in the Proton Drive app" width="820">
|
||||
|
||||
@@ -148,7 +149,7 @@ keys before it leaves the machine, through a Rust implementation of Proton's Dri
|
||||
|
||||
Files you open are decrypted into a local cache so programs can read them. What that means for
|
||||
the security of the machine is spelled out in the
|
||||
[threat model](docs/ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||
[threat model](docs/ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -156,6 +157,13 @@ Bug reports, translations and patches are welcome. See [CONTRIBUTING.md](CONTRIB
|
||||
workflow and the checks CI runs, and [SECURITY.md](SECURITY.md) to report a vulnerability
|
||||
privately.
|
||||
|
||||
## Support
|
||||
|
||||
Proton Drive Linux is free and built in spare time. If it saves you a few headaches, you can
|
||||
[buy me a coffee](https://buymeacoffee.com/narl).
|
||||
|
||||
<a href="https://buymeacoffee.com/narl"><img src="https://img.shields.io/badge/Buy%20me%20a%20coffee-FFDD00?logo=buymeacoffee&logoColor=black" alt="Buy me a coffee"></a>
|
||||
|
||||
## License
|
||||
|
||||
Released under the [MIT License](LICENSE).
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ the interesting boundaries are:
|
||||
or the wrong key material to Proton.
|
||||
|
||||
What the client deliberately keeps in plaintext on disk, and why, is documented in the
|
||||
[threat model](docs/ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||
[threat model](docs/ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||
Behaviour described there is known and not a vulnerability by itself; a way to make it worse
|
||||
than described is.
|
||||
|
||||
|
||||
@@ -23,3 +23,4 @@ tracing-subscriber.workspace = true
|
||||
clap.workspace = true
|
||||
rpassword.workspace = true
|
||||
serde_json.workspace = true
|
||||
zeroize.workspace = true
|
||||
+257
-35
@@ -135,6 +135,24 @@ enum Command {
|
||||
/// Account email; prompted if omitted.
|
||||
#[arg(short, long)]
|
||||
username: Option<String>,
|
||||
/// For a two-password account, keep the mailbox password's unlock in the
|
||||
/// system keyring so the daemon starts without asking after every login.
|
||||
/// Without it the unlock lives in kernel memory for this session only.
|
||||
#[arg(long)]
|
||||
remember: bool,
|
||||
},
|
||||
/// Enter the mailbox password of a two-password account so the daemon can
|
||||
/// unlock your files. Needed once per login session.
|
||||
Unlock {
|
||||
/// Keep the unlock in the system keyring instead of kernel memory only.
|
||||
#[arg(long)]
|
||||
remember: bool,
|
||||
},
|
||||
/// Forget the unlock held in kernel memory; the account is locked again.
|
||||
Lock {
|
||||
/// Also remove an unlock kept in the keyring with `--remember`.
|
||||
#[arg(long)]
|
||||
forget: bool,
|
||||
},
|
||||
/// Forget the stored session.
|
||||
Logout,
|
||||
@@ -610,6 +628,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)]
|
||||
@@ -803,7 +830,9 @@ fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
JSON_OUTPUT.store(cli.json, std::sync::atomic::Ordering::Relaxed);
|
||||
match cli.command {
|
||||
Command::Login { username } => cmd_login(username),
|
||||
Command::Login { username, remember } => cmd_login(username, remember),
|
||||
Command::Unlock { remember } => cmd_unlock(remember),
|
||||
Command::Lock { forget } => cmd_lock(forget),
|
||||
Command::Logout => cmd_logout(),
|
||||
Command::Status => cmd_status(),
|
||||
Command::Locations => cmd_locations(),
|
||||
@@ -947,6 +976,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(())
|
||||
}
|
||||
@@ -1781,7 +1828,7 @@ fn prompt_line(label: &str) -> Result<String> {
|
||||
Ok(line.trim().to_owned())
|
||||
}
|
||||
|
||||
fn cmd_login(username: Option<String>) -> Result<()> {
|
||||
fn cmd_login(username: Option<String>, remember: bool) -> Result<()> {
|
||||
let username = match username {
|
||||
Some(u) => u,
|
||||
None => prompt_line("Proton email")?,
|
||||
@@ -1793,20 +1840,32 @@ fn cmd_login(username: Option<String>) -> Result<()> {
|
||||
prompt_line("2FA code").map_err(|e| pdfs_core::Error::Other(format!("read 2FA code: {e}")))
|
||||
};
|
||||
|
||||
// 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))
|
||||
.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,
|
||||
))
|
||||
.context("login failed")?;
|
||||
|
||||
// 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
|
||||
@@ -1818,9 +1877,88 @@ fn cmd_login(username: Option<String>) -> Result<()> {
|
||||
}
|
||||
|
||||
println!("Logged in as {username}. Session stored in the system keyring.");
|
||||
report_key_state();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ask for the mailbox password of a two-password account, without echo.
|
||||
fn prompt_mailbox(attempt: u32, remember: bool) -> pdfs_core::Result<auth::Mailbox> {
|
||||
if attempt == 0 {
|
||||
eprintln!("This account has a separate mailbox password.");
|
||||
} else {
|
||||
eprintln!("That mailbox password did not unlock the account. Try again.");
|
||||
}
|
||||
let password = rpassword::prompt_password("Mailbox password: ")
|
||||
.map_err(|e| pdfs_core::Error::Other(format!("read mailbox password: {e}")))?;
|
||||
Ok(auth::Mailbox {
|
||||
password: zeroize::Zeroizing::new(password),
|
||||
remember,
|
||||
})
|
||||
}
|
||||
|
||||
/// Say where the account's keys are held, and what that means for the next login.
|
||||
fn report_key_state() {
|
||||
match auth::key_state() {
|
||||
Ok(auth::KeyState::Session) => println!(
|
||||
"Unlocked for this login session only. After you log out or reboot, run `pdfs unlock`."
|
||||
),
|
||||
Ok(auth::KeyState::Locked) => println!("The account is locked. Run `pdfs unlock`."),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_unlock(remember: bool) -> Result<()> {
|
||||
auth::load().context("not logged in (run `pdfs login` first)")?;
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
// Bounded like the login prompt: three wrong answers end the command.
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
let mailbox = prompt_mailbox(attempt, remember)?;
|
||||
match rt.block_on(auth::unlock(&mailbox.password, remember)) {
|
||||
Ok(()) => break,
|
||||
Err(pdfs_core::Error::WrongMailboxPassword) if attempt < 2 => attempt += 1,
|
||||
Err(e) => return Err(anyhow::Error::new(e).context("unlock failed")),
|
||||
}
|
||||
}
|
||||
// A daemon parked on the locked account would otherwise notice on its next
|
||||
// poll; restarting it makes the mount appear now, as after `pdfs login`.
|
||||
if service::is_active() {
|
||||
service::restart();
|
||||
}
|
||||
println!("Unlocked.");
|
||||
report_key_state();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_lock(forget: bool) -> Result<()> {
|
||||
auth::lock(forget)?;
|
||||
println!(
|
||||
"Locked. {}",
|
||||
if forget {
|
||||
"Unlock removed from memory and keyring."
|
||||
} else {
|
||||
"Run `pdfs unlock` to unlock again."
|
||||
}
|
||||
);
|
||||
// The running daemon keeps its keys until it restarts; say so, since
|
||||
// "locked" would otherwise suggest the mount stopped.
|
||||
if service::is_active() {
|
||||
println!(
|
||||
"The running daemon keeps its keys until it restarts: `systemctl --user restart proton-drive`."
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `stored`, `session` or `locked`: where the account's keys are held.
|
||||
fn key_state_name() -> &'static str {
|
||||
match auth::key_state() {
|
||||
Ok(auth::KeyState::Stored) => "stored",
|
||||
Ok(auth::KeyState::Session) => "session",
|
||||
_ => "locked",
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_logout() -> Result<()> {
|
||||
auth::logout()?;
|
||||
println!("Stored session cleared.");
|
||||
@@ -1848,6 +1986,7 @@ fn cmd_status() -> Result<()> {
|
||||
"username": session.as_ref().map(|s| s.username.clone()),
|
||||
"user_id": session.as_ref().map(|s| s.user_id.clone()),
|
||||
"scopes": session.as_ref().map(|s| s.scopes.clone()),
|
||||
"keys": session.as_ref().map(|_| key_state_name()),
|
||||
"mount": mount,
|
||||
});
|
||||
println!("{}", serde_json::to_string_pretty(&out)?);
|
||||
@@ -1858,6 +1997,11 @@ fn cmd_status() -> Result<()> {
|
||||
Ok(s) => {
|
||||
println!("Logged in as {} (user {})", s.username, s.user_id);
|
||||
println!("Scopes: {}", s.scopes.join(", "));
|
||||
match key_state_name() {
|
||||
"locked" => println!("Keys: locked — run `pdfs unlock`"),
|
||||
"session" => println!("Keys: unlocked for this login session"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(pdfs_core::Error::NotLoggedIn) => {
|
||||
println!("Not logged in. Run `pdfs login`.");
|
||||
@@ -1880,9 +2024,12 @@ fn cmd_status() -> Result<()> {
|
||||
staged_oldest_secs,
|
||||
paused,
|
||||
paused_until,
|
||||
session_expired,
|
||||
tokens_unsaved,
|
||||
..
|
||||
}) => {
|
||||
let state = match (online, paused) {
|
||||
_ if session_expired => ", session expired: run `pdfs login`",
|
||||
(_, true) => ", sync paused",
|
||||
(false, false) => ", offline",
|
||||
(true, false) => "",
|
||||
@@ -1915,6 +2062,11 @@ 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`"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(other) => println!("Mount: unexpected response {other:?}"),
|
||||
Err(_) => println!("Mount: not running."),
|
||||
@@ -1989,7 +2141,7 @@ fn login_poll_delay(attempts: u32) -> std::time::Duration {
|
||||
std::time::Duration::from_secs(BASE_SECS.saturating_mul(factor).min(MAX_SECS))
|
||||
}
|
||||
|
||||
/// Block until a session is stored, backing off between checks.
|
||||
/// Block until a session is stored and unlocked, backing off between checks.
|
||||
///
|
||||
/// `load` and `sleep` are parameters rather than direct calls so the backoff
|
||||
/// schedule can be tested without a keyring and without real time passing.
|
||||
@@ -2014,6 +2166,16 @@ where
|
||||
sleep(delay);
|
||||
attempts = attempts.saturating_add(1);
|
||||
}
|
||||
Err(pdfs_core::Error::Locked) => {
|
||||
let delay = login_poll_delay(attempts);
|
||||
if attempts == 0 {
|
||||
tracing::info!("account is locked; waiting for `pdfs unlock`…");
|
||||
} else {
|
||||
tracing::debug!(delay_secs = delay.as_secs(), "account still locked");
|
||||
}
|
||||
sleep(delay);
|
||||
attempts = attempts.saturating_add(1);
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
@@ -2024,25 +2186,31 @@ where
|
||||
/// an external unmount triggers a remount; errors back off and retry. This is
|
||||
/// the entry point for the systemd user service.
|
||||
fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
|
||||
// The daemon holds the account's key passphrases in memory; keep them out
|
||||
// of core dumps and away from same-user ptrace.
|
||||
pdfs_core::kernelkey::forbid_dumps();
|
||||
loop {
|
||||
// Wait until a session is stored. The GUI enables this service on login,
|
||||
// but the service may also start at boot before the user has logged in.
|
||||
wait_for_session(|| auth::load().map(|_| ()), std::thread::sleep)?;
|
||||
wait_for_session(auth::ready, pdfs_fuse::idle_sleep)?;
|
||||
|
||||
match mount_once(mountpoint.clone()) {
|
||||
Ok(pdfs_fuse::MountOutcome::Shutdown) => {
|
||||
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");
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
pdfs_fuse::idle_sleep(std::time::Duration::from_secs(2));
|
||||
}
|
||||
Err(e) => {
|
||||
// `{:#}` so the whole anyhow chain lands in the log — the top
|
||||
// context alone ("mount failed") never says *why*.
|
||||
tracing::error!(error = format!("{e:#}"), "mount failed; retrying in 5s");
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
pdfs_fuse::idle_sleep(std::time::Duration::from_secs(5));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2104,9 +2272,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).
|
||||
@@ -3224,6 +3397,19 @@ fn cmd_diagnose() -> Result<()> {
|
||||
match pdfs_core::auth::load() {
|
||||
Ok(session) => {
|
||||
report.finding(DiagnoseLevel::Ok, " keyring session", session.username);
|
||||
match key_state_name() {
|
||||
"locked" => report.finding(
|
||||
DiagnoseLevel::Fail,
|
||||
" account keys",
|
||||
"locked — run `pdfs unlock` (the daemon waits for it)",
|
||||
),
|
||||
"session" => report.finding(
|
||||
DiagnoseLevel::Ok,
|
||||
" account keys",
|
||||
"unlocked for this login session",
|
||||
),
|
||||
_ => report.finding(DiagnoseLevel::Ok, " account keys", "stored in the keyring"),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// A locked keyring and an absent login look the same from here, so
|
||||
@@ -3254,6 +3440,8 @@ fn cmd_diagnose() -> Result<()> {
|
||||
Ok(CtlResponse::Status {
|
||||
mountpoint,
|
||||
online,
|
||||
session_expired,
|
||||
tokens_unsaved,
|
||||
pending_uploads,
|
||||
pending_changes,
|
||||
..
|
||||
@@ -3275,19 +3463,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 {
|
||||
@@ -3522,6 +3729,21 @@ mod login_wait_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_account_is_waited_on_until_it_is_unlocked() {
|
||||
let mut checks = vec![
|
||||
Ok(()),
|
||||
Err(pdfs_core::Error::Locked),
|
||||
Err(pdfs_core::Error::NotLoggedIn),
|
||||
Err(pdfs_core::Error::Locked),
|
||||
];
|
||||
let mut slept = 0;
|
||||
|
||||
wait_for_session(|| checks.pop().unwrap(), |_| slept += 1).unwrap();
|
||||
|
||||
assert_eq!(slept, 3, "locked and signed-out are both waited out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_session_is_not_waited_for_at_all() {
|
||||
let mut slept = 0;
|
||||
|
||||
@@ -28,3 +28,5 @@ dbus-secret-service-keyring-store.workspace = true
|
||||
parking_lot.workspace = true
|
||||
async-trait.workspace = true
|
||||
libc.workspace = true
|
||||
zeroize.workspace = true
|
||||
base64.workspace = true
|
||||
+1018
-120
File diff suppressed because it is too large.
Load diff
@@ -91,7 +91,7 @@ const TOUCH_FLUSH_AT: usize = 256;
|
||||
///
|
||||
/// Public because it is the *contract* a block has to satisfy, not a detail of
|
||||
/// this cache: the network read path validates against it too, so a block that
|
||||
/// comes back short is refused before it can truncate a file (bugs.md B84).
|
||||
/// comes back short is refused before it can truncate a file (`docs/BUGS.md` B84).
|
||||
pub fn block_len(size: u64, idx: u64) -> u64 {
|
||||
let start = idx.saturating_mul(BLOCK_SIZE);
|
||||
size.saturating_sub(start).min(BLOCK_SIZE)
|
||||
@@ -311,8 +311,7 @@ struct PinFile {
|
||||
pins: BTreeMap<String, Pin>,
|
||||
}
|
||||
|
||||
/// What a staged file actually contains, written beside it as `<name>.json`
|
||||
/// (offline.md Phase 2).
|
||||
/// What a staged file actually contains, written beside it as `<name>.json`.
|
||||
///
|
||||
/// A staged file is **not** necessarily valid whole-file content, and that is
|
||||
/// the whole reason this exists. A partial overwrite commits by filling the
|
||||
@@ -439,9 +438,9 @@ pub struct ContentCache {
|
||||
/// Subdirectory for write-handle scratch files (disk-backed write buffers).
|
||||
/// Emptied on open so a crashed run leaves no orphans.
|
||||
scratch_dir: PathBuf,
|
||||
/// Subdirectory holding the bytes of writes that have not been uploaded yet
|
||||
/// (offline.md Phase 2/3) — every released write passes through here, since
|
||||
/// the upload is a queued op performed later. Unlike `scratch_dir` this is
|
||||
/// Subdirectory holding the bytes of writes that have not been uploaded yet.
|
||||
/// Every released write passes through here, since the upload is a queued
|
||||
/// op performed later. Unlike `scratch_dir` this is
|
||||
/// **never** emptied on open: these are the only copy of content the user
|
||||
/// authored, and the whole point is that they outlive the daemon.
|
||||
staging_dir: PathBuf,
|
||||
@@ -476,7 +475,7 @@ pub struct ContentCache {
|
||||
thumb_bytes: AtomicU64,
|
||||
/// Unified metadata DB. Its `cache_entries` table is the LRU index: every
|
||||
/// store/read/evict updates it, and the budget enforcers query it instead of
|
||||
/// scanning the cache directories (plan.md P4).
|
||||
/// scanning the cache directories.
|
||||
db: Arc<Db>,
|
||||
/// LRU touches not yet written to `cache_entries`, keyed by cache key.
|
||||
///
|
||||
@@ -989,7 +988,7 @@ impl ContentCache {
|
||||
/// a miss ([`cached_block`](Self::cached_block) checks), so a bad block
|
||||
/// cannot escape this cache even though this does not check. Validating a
|
||||
/// block is the *reader's* job, at the point it is fetched — see
|
||||
/// `Core::read_block` (bugs.md B84).
|
||||
/// `Core::read_block` (`docs/BUGS.md` B84).
|
||||
pub fn store_block(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
@@ -1491,9 +1490,8 @@ impl ContentCache {
|
||||
/// This is what makes a write survive its upload: the caller is releasing a
|
||||
/// write handle and would otherwise delete the file, so until the bytes are
|
||||
/// on the remote, staging holds the only copy. Every dirty handle goes
|
||||
/// through here — the upload is a queued op performed later (offline.md
|
||||
/// Phase 3), and a staged file is also what a human can recover from if the
|
||||
/// queue never drains.
|
||||
/// through here — the upload is a queued op performed later, and a staged
|
||||
/// file is also what a human can recover from if the queue never drains.
|
||||
///
|
||||
/// Falls back to a copy when the rename crosses a filesystem boundary; a
|
||||
/// failure here means we could not save the bytes at all, so it is reported
|
||||
@@ -2311,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
|
||||
@@ -2707,7 +2689,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The geometry the whole read path validates against, here and over the
|
||||
/// network (bugs.md B84): full blocks, then whatever is left.
|
||||
/// network (`docs/BUGS.md` B84): full blocks, then whatever is left.
|
||||
#[test]
|
||||
fn block_len_is_full_blocks_then_the_remainder() {
|
||||
let size = BLOCK_SIZE * 2 + 17;
|
||||
@@ -2787,7 +2769,7 @@ mod tests {
|
||||
/// A cached block is addressed by the range it covers, not by its index
|
||||
/// alone. Re-planning a file onto its real geometry must therefore miss the
|
||||
/// blocks the uniform fallback cached, rather than serve their bytes at the
|
||||
/// wrong offset (bugs.md B85).
|
||||
/// wrong offset (`docs/BUGS.md` B85).
|
||||
#[test]
|
||||
fn a_cached_block_is_not_served_to_a_different_geometry() {
|
||||
let (c, _d) = cache();
|
||||
@@ -2854,7 +2836,7 @@ mod tests {
|
||||
|
||||
/// A block of the wrong length cannot escape the cache even though the
|
||||
/// store does not check: the read validates it and reports a miss, so the
|
||||
/// next read refetches rather than serving a hole (bugs.md B84).
|
||||
/// next read refetches rather than serving a hole (`docs/BUGS.md` B84).
|
||||
#[test]
|
||||
fn a_block_of_the_wrong_length_reads_as_a_miss() {
|
||||
let (c, _d) = cache();
|
||||
@@ -2919,7 +2901,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A failed upload must never cost the user their bytes (offline.md Phase 2):
|
||||
/// A failed upload must never cost the user their bytes:
|
||||
/// the scratch file moves to staging intact, and staging survives a reopen
|
||||
/// the way scratch deliberately does not.
|
||||
#[test]
|
||||
|
||||
+110
-38
@@ -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.
|
||||
@@ -157,7 +182,7 @@ pub struct AppConfig {
|
||||
/// Record a new folder, a new file, a rename or a delete through the mount
|
||||
/// locally and send it from the queue, instead of waiting for Drive in the
|
||||
/// syscall. `None` means on. `false` brings back the old path for one
|
||||
/// release (docs/MILESTONE-3.0.0.md §10). Defaulted for configs predating
|
||||
/// release (docs/ROADMAP.md). Defaulted for configs predating
|
||||
/// the field.
|
||||
#[serde(default)]
|
||||
pub local_first: Option<bool>,
|
||||
@@ -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 {
|
||||
@@ -432,7 +454,7 @@ impl AppDirs {
|
||||
/// typical umask makes them `0755`. Until this was fixed, the only thing
|
||||
/// keeping another local user out was `~/.cache` and `~/.local/state`
|
||||
/// happening to be `0700`, which is a convention of the user's system and
|
||||
/// not something this client established (bugs.md B6).
|
||||
/// not something this client established (`docs/BUGS.md` B6).
|
||||
///
|
||||
/// Applied on every start, not just at creation: a directory that already
|
||||
/// exists with a permissive mode — restored from a backup that flattened
|
||||
@@ -483,7 +505,7 @@ fn restrict_dir(dir: &Path) -> Result<()> {
|
||||
/// session — enumerate, read, upload, trash, share — without presenting any
|
||||
/// credential, since the daemon already holds one. It is an authority boundary,
|
||||
/// not merely private data, and `UnixListener::bind` applies the umask like any
|
||||
/// other file (bugs.md B6).
|
||||
/// other file (`docs/BUGS.md` B6).
|
||||
///
|
||||
/// There is a window between `bind` and this call during which the socket
|
||||
/// carries the umask's mode. It is closed in practice by the containing
|
||||
@@ -504,7 +526,7 @@ mod tests {
|
||||
std::fs::metadata(p).unwrap().permissions().mode() & 0o777
|
||||
}
|
||||
|
||||
/// bugs.md B6. A permissive mode here is not a cosmetic problem: these
|
||||
/// `docs/BUGS.md` B6. A permissive mode here is not a cosmetic problem: these
|
||||
/// directories hold decrypted content, a plaintext index of every node name,
|
||||
/// and the control socket that commands the daemon's session.
|
||||
#[test]
|
||||
@@ -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]
|
||||
|
||||
@@ -443,11 +443,18 @@ pub enum Request {
|
||||
DeleteDevice { uid: String },
|
||||
/// Adopt an existing device as this machine's, pinning its uid in
|
||||
/// `config.json` so a hostname change or reinstall re-attaches to it instead
|
||||
/// of registering a duplicate (features.md 5.1). `uid: None` clears the pin.
|
||||
/// 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 (devices.md) ----------------------------------
|
||||
// ---- device folder sync ----------------------------------
|
||||
/// Add a local folder to this machine's device, uploading its tree and
|
||||
/// registering the device on first use. Replies with [`Response::Ok`].
|
||||
AddSyncFolder { local_path: String },
|
||||
@@ -496,7 +503,7 @@ pub enum Request {
|
||||
/// [`Response::Ok`].
|
||||
ResolveConflict { path: String, keep: ConflictKeep },
|
||||
/// List the folders under this machine's device that can be synced here,
|
||||
/// each with a proposed local path (features.md 5.2). Replies with
|
||||
/// each with a proposed local path. Replies with
|
||||
/// [`Response::RestorableFolders`].
|
||||
ListRestorableFolders,
|
||||
/// Attach the given remote device folders to local paths and sync them down.
|
||||
@@ -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.
|
||||
@@ -1903,17 +1916,17 @@ pub enum Response {
|
||||
/// The pin registry.
|
||||
pins: Vec<Pin>,
|
||||
/// False when the daemon is serving the cached tree because the API is
|
||||
/// unreachable (offline.md Phase 1). Cached and pinned content still
|
||||
/// unreachable. Cached and pinned content still
|
||||
/// reads; anything else fails until the network is back.
|
||||
#[serde(default = "default_online")]
|
||||
online: bool,
|
||||
/// Writes accepted locally but not yet uploaded (offline.md Phase 3).
|
||||
/// Writes accepted locally but not yet uploaded.
|
||||
/// Non-zero means the mount is ahead of the remote — either a copy is
|
||||
/// still draining, or it cannot drain because we are offline.
|
||||
#[serde(default)]
|
||||
pending_uploads: u64,
|
||||
/// Queued mutations that carry no bytes: `mkdir`, `rename`, `trash`
|
||||
/// (offline.md Phase 3b). Counted apart from `pending_uploads` because
|
||||
/// Queued mutations that carry no bytes: `mkdir`, `rename`, `trash`.
|
||||
/// Counted apart from `pending_uploads` because
|
||||
/// calling a queued `mkdir` an upload is a lie.
|
||||
#[serde(default)]
|
||||
pending_changes: u64,
|
||||
@@ -1946,6 +1959,16 @@ 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,
|
||||
},
|
||||
/// A human-readable success message.
|
||||
Ok { message: String },
|
||||
@@ -3082,6 +3105,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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Database health reporting and repair (features.md 4.3).
|
||||
//! Database health reporting and repair.
|
||||
//!
|
||||
//! Everything here exists for the case where something has already gone wrong:
|
||||
//! a crash mid-write, a full disk, a cache that has grown out of proportion to
|
||||
|
||||
@@ -577,7 +577,7 @@ CREATE TABLE trash (
|
||||
);
|
||||
";
|
||||
|
||||
/// Schema v8: device sync (devices.md). This machine registers as one Proton Drive
|
||||
/// Schema v8: device sync. This machine registers as one Proton Drive
|
||||
/// **Device** (a share + root folder on the main volume); `device` is a singleton
|
||||
/// row cached so we reuse the same device across restarts instead of creating a
|
||||
/// new one each run. `sync_folder` is one row per local folder the user added,
|
||||
@@ -638,8 +638,8 @@ ALTER TABLE sync_folder ADD COLUMN pending_mode TEXT;
|
||||
";
|
||||
|
||||
/// Schema v11: writes no longer upload inside the FUSE handler. `release` stages
|
||||
/// the bytes and records the intended upload here, and a drain worker performs it
|
||||
/// (offline.md Phase 3). The row outlives the process, so a write survives both a
|
||||
/// the bytes and records the intended upload here, and a drain worker performs it.
|
||||
/// The row outlives the process, so a write survives both a
|
||||
/// dead network and a restart.
|
||||
///
|
||||
/// `blob_path` points into the content cache's `staging/` dir and `meta_json` is
|
||||
@@ -666,7 +666,7 @@ CREATE INDEX pending_op_uid ON pending_op(uid);
|
||||
|
||||
/// Schema v12: `pending_op` also carries mutations that *create* a node, which a
|
||||
/// revision op never had to describe — it always addressed a node the server had
|
||||
/// already minted a uid for (offline.md Phase 3b).
|
||||
/// already minted a uid for.
|
||||
///
|
||||
/// An offline `create`/`mkdir` cannot get a real uid, so the node is invented
|
||||
/// locally under a `local~<uuid>` placeholder and the op records where it goes
|
||||
@@ -1056,7 +1056,7 @@ ALTER TABLE pending_op ADD COLUMN access_deferred_since INTEGER NOT NULL DEFAULT
|
||||
/// as the last sync and left the file the same length — a fixed-size record
|
||||
/// rewritten, an image re-exported, a database page updated in place — read as
|
||||
/// *unchanged* and was never uploaded. The remote copy silently stayed at the
|
||||
/// older content until something else about the file moved (bugs.md B25).
|
||||
/// older content until something else about the file moved (`docs/BUGS.md` B25).
|
||||
///
|
||||
/// Nullable rather than a converted `local_mtime * 1_000_000_000`: the
|
||||
/// nanosecond part of an existing baseline is genuinely unknown, and inventing
|
||||
@@ -1168,8 +1168,8 @@ const MIGRATION_V35: &str = "
|
||||
ALTER TABLE trash ADD COLUMN trashed_at INTEGER;
|
||||
";
|
||||
|
||||
/// Schema v36: every node row has a local id (`lid`), phase 2 of
|
||||
/// docs/MILESTONE-3.0.0.md.
|
||||
/// Schema v36: every node row has a local id (`lid`); see
|
||||
/// docs/ARCHITECTURE.md §2.
|
||||
///
|
||||
/// The `uid` was the row's key, so a node had no identity until Drive minted
|
||||
/// one, and a create that landed moved its row to a new key. The `lid` is given
|
||||
|
||||
@@ -568,8 +568,7 @@ impl Db {
|
||||
}
|
||||
|
||||
/// Load one persisted node back by uid. Used to recover the My Files root
|
||||
/// when the API is unreachable, so the mount can still serve the cached tree
|
||||
/// (offline.md Phase 1).
|
||||
/// when the API is unreachable, so the mount can still serve the cached tree.
|
||||
pub fn node_by_uid(&self, uid: &str) -> Result<Option<Node>> {
|
||||
let conn = self.read();
|
||||
let json: Option<String> = conn
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::Result;
|
||||
pub const OP_REVISION: &str = "revision";
|
||||
|
||||
/// The `kind` of a [`PendingOp`] that creates a file that so far exists only
|
||||
/// locally, under a `local:` placeholder uid (offline.md Phase 3b).
|
||||
/// locally, under a `local:` placeholder uid.
|
||||
///
|
||||
/// The written bytes ride along on the same row rather than as a follow-on
|
||||
/// [`OP_REVISION`]: draining the create mints the node's real uid, which would
|
||||
@@ -29,7 +29,7 @@ pub const OP_CREATE: &str = "create";
|
||||
pub const OP_MKDIR: &str = "mkdir";
|
||||
|
||||
/// The `kind` of a [`PendingOp`] that gives a node a new name, a new parent, or
|
||||
/// both — the queued form of `mv` (offline.md Phase 3b).
|
||||
/// both — the queued form of `mv`.
|
||||
///
|
||||
/// `parent_uid` and `name` hold the node's *desired end state*, not a delta, so
|
||||
/// a second rename simply replaces the row (see [`Db::enqueue_op`]) and the
|
||||
@@ -55,8 +55,7 @@ pub struct RenameMeta {
|
||||
pub original_name: Option<String>,
|
||||
}
|
||||
|
||||
/// The `kind` of a [`PendingOp`] that trashes a node the server knows about
|
||||
/// (offline.md Phase 3b).
|
||||
/// The `kind` of a [`PendingOp`] that trashes a node the server knows about.
|
||||
///
|
||||
/// A node that only ever existed locally never gets one of these: there is
|
||||
/// nothing to trash remotely, so deleting it just drops its queued ops
|
||||
@@ -253,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.
|
||||
@@ -311,7 +329,7 @@ pub const PARK_UNTIL: i64 = 8_000_000_000_000;
|
||||
pub const PARK_EXPIRY_MS: i64 = 60 * 60 * 1000;
|
||||
|
||||
/// A mutation that has been accepted locally but not yet performed against the
|
||||
/// API — the durable half of the write-back queue (offline.md Phase 3).
|
||||
/// API — the durable half of the write-back queue.
|
||||
///
|
||||
/// The daemon answers the FUSE call the moment this row and its staged blob are
|
||||
/// on disk, so a `cp` into the mount runs at disk speed and the upload happens
|
||||
@@ -887,7 +905,7 @@ impl Db {
|
||||
/// make — the node is still only a queued intent, and rewriting that intent
|
||||
/// *is* the rename. Returns false when the create has already drained, in
|
||||
/// which case the node has a real uid and the caller must rename it there
|
||||
/// instead (offline.md Phase 3b).
|
||||
/// instead.
|
||||
///
|
||||
/// A create backing off is made due: what it failed on may have been the
|
||||
/// name it no longer wants, such as one held by a queued change. A parked
|
||||
@@ -1128,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 {
|
||||
@@ -1209,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),
|
||||
|
||||
@@ -25,8 +25,8 @@ pub struct StoredSyncFolder {
|
||||
}
|
||||
|
||||
/// One per-file sync baseline row: what a path looked like on both sides at the
|
||||
/// last successful sync, so the next reconcile can tell which side changed
|
||||
/// (devices.md Phase 2). `remote_rev`/`remote_hash` hold the remote signature —
|
||||
/// last successful sync, so the next reconcile can tell which side changed.
|
||||
/// `remote_rev`/`remote_hash` hold the remote signature —
|
||||
/// its modification time and size as strings — since no cheap content hash is
|
||||
/// exposed; change detection is `(mtime, size)` on each side.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -39,7 +39,7 @@ pub struct StoredSyncEntry {
|
||||
///
|
||||
/// `local_mtime` alone cannot tell an edit that landed inside the same
|
||||
/// second as the last sync — and left the file the same length — from no
|
||||
/// edit at all, so those never uploaded (bugs.md B25). `None` on a row
|
||||
/// edit at all, so those never uploaded (`docs/BUGS.md` B25). `None` on a row
|
||||
/// written before schema 28, which the comparison reads as "compare whole
|
||||
/// seconds", so upgrading does not make an entire mirror look changed.
|
||||
pub local_mtime_ns: Option<i64>,
|
||||
@@ -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<()> {
|
||||
@@ -345,7 +356,7 @@ impl Db {
|
||||
/// Drop the entire baseline for a folder. Used when flipping ondemand→mirror:
|
||||
/// the local tree was evicted, so the old baseline is stale and would make the
|
||||
/// next reconcile mistake "locally deleted" for "must re-download". Clearing it
|
||||
/// leaves an empty baseline + full remote = pure download (devices.md P3).
|
||||
/// leaves an empty baseline + full remote = pure download.
|
||||
pub fn sync_entries_clear(&self, folder_id: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
|
||||
@@ -675,8 +675,8 @@ fn foreign_folder(volume: &str, link: &str, parent: NodeUid, name: &str) -> Node
|
||||
node
|
||||
}
|
||||
|
||||
/// Recovering the root by uid is what lets the daemon mount offline
|
||||
/// (offline.md Phase 1): the uid is remembered in `sync_state`, the node
|
||||
/// Recovering the root by uid is what lets the daemon mount offline:
|
||||
/// the uid is remembered in `sync_state`, the node
|
||||
/// itself comes back out of `nodes`.
|
||||
#[test]
|
||||
fn node_by_uid_recovers_a_stored_node() {
|
||||
@@ -796,7 +796,7 @@ fn children_if_listed_gated_on_flag() {
|
||||
assert_eq!(kids.len(), 2);
|
||||
}
|
||||
|
||||
/// The shape of the `mv`-loses-the-file bug (bugs.md B1): a rename deletes the
|
||||
/// The shape of the `mv`-loses-the-file bug (`docs/BUGS.md` B1): a rename deletes the
|
||||
/// moved node's row, so a destination left marked `listed` is rebuilt from the DB
|
||||
/// without it — the file is gone from the source and absent from the destination,
|
||||
/// with `rename(2)` having reported success. Clearing the flag is what forces the
|
||||
@@ -4940,6 +4940,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();
|
||||
@@ -5735,7 +5757,7 @@ fn cache_eviction_candidates_are_lru_ordered_and_limited() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The B5 checkpoint.** `improvements.md` P2.3 proposes concurrent SQLite
|
||||
/// **The B5 checkpoint.** A performance plan proposed concurrent SQLite
|
||||
/// reads, on the premise that the single `Mutex<Connection>` serializes the FUSE
|
||||
/// workers. This measures what the connection is actually asked to do now that
|
||||
/// B3 and B4 have landed, so the proposal is decided on evidence.
|
||||
@@ -6291,7 +6313,7 @@ fn migration_v27_adds_the_access_deferral_column_to_a_v26_queue() {
|
||||
/// back with no nanosecond time. That `None` is the whole point: it makes the
|
||||
/// comparison fall back to whole seconds for rows written before the column
|
||||
/// existed, instead of asserting a sub-second time of zero and re-uploading
|
||||
/// every already-synced file in the folder (bugs.md B25).
|
||||
/// every already-synced file in the folder (`docs/BUGS.md` B25).
|
||||
#[test]
|
||||
fn migration_v28_adds_sub_second_local_times_without_disturbing_a_v27_baseline() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
@@ -6525,6 +6547,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]
|
||||
|
||||
@@ -102,5 +102,5 @@ impl Db {
|
||||
Ok(seen)
|
||||
}
|
||||
|
||||
// ---- device sync (devices.md) -----------------------------------------
|
||||
// ---- device sync -----------------------------------------
|
||||
}
|
||||
@@ -32,6 +32,16 @@ pub enum Error {
|
||||
#[error("saved session can no longer unlock your keys — run `pdfs login` again")]
|
||||
ReloginRequired,
|
||||
|
||||
/// The account has a separate mailbox password that has not been entered
|
||||
/// since login, so its keys cannot be unlocked. The fix is
|
||||
/// [`auth::unlock`](crate::auth::unlock), not a new login.
|
||||
#[error("account is locked — enter the mailbox password with `pdfs unlock`")]
|
||||
Locked,
|
||||
|
||||
/// The mailbox password was checked against the account and is wrong.
|
||||
#[error("that mailbox password does not unlock your account")]
|
||||
WrongMailboxPassword,
|
||||
|
||||
/// The login was gated behind human verification. Not a failure the user can
|
||||
/// fix by retrying: the challenge has to be solved and the login restarted
|
||||
/// with the resulting token (see [`auth::login_verified`](crate::auth::login_verified)).
|
||||
@@ -199,9 +209,11 @@ impl From<serde_json::Error> for CoreError {
|
||||
impl From<Error> for CoreError {
|
||||
fn from(e: Error) -> Self {
|
||||
let kind = match &e {
|
||||
Error::NotLoggedIn | Error::ReloginRequired | Error::HumanVerificationRequired(_) => {
|
||||
ErrorKind::Denied
|
||||
}
|
||||
Error::NotLoggedIn
|
||||
| Error::ReloginRequired
|
||||
| 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,
|
||||
@@ -266,6 +278,23 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// The result every request-serving method in the daemon returns.
|
||||
pub type CoreResult<T> = std::result::Result<T, CoreError>;
|
||||
|
||||
@@ -297,6 +326,17 @@ 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));
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//! A secret held in the Linux kernel keyring, for as long as the user is logged in.
|
||||
//!
|
||||
//! This is where the key passphrases of a two-password account live by default:
|
||||
//! the mailbox password is never stored, and the secrets derived from it are
|
||||
//! 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
|
||||
//! process's heap. Processes of the same user can still read them, exactly as
|
||||
//! they could ptrace the daemon: this protects against disk, backup and other
|
||||
//! users, not against code already running as you.
|
||||
//!
|
||||
//! The keyring may be unavailable (a container, a kernel built without
|
||||
//! `CONFIG_KEYS`); every operation then reports it as an error and the caller
|
||||
//! falls back to asking again, never to storing the secret somewhere weaker.
|
||||
|
||||
use std::io;
|
||||
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// 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))]
|
||||
pub const PASSPHRASES: Key = Key {
|
||||
description: b"pdfs:key-passphrases\0",
|
||||
};
|
||||
/// Tests must never touch a real unlock.
|
||||
#[cfg(test)]
|
||||
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;
|
||||
const KEYCTL_REVOKE: libc::c_long = 3;
|
||||
const KEYCTL_SETPERM: libc::c_long = 5;
|
||||
const KEYCTL_SEARCH: libc::c_long = 10;
|
||||
const KEYCTL_READ: libc::c_long = 11;
|
||||
|
||||
/// Possessor and owner may view, read, write, search, link and set attributes;
|
||||
/// group and others get nothing.
|
||||
///
|
||||
/// A new key's default grants the owner only *view*, and read access to the
|
||||
/// possessor. A process possesses the user keyring only when its session
|
||||
/// keyring links to it, which a systemd user service or a container does not
|
||||
/// guarantee — so with the default, the daemon could store a key it then
|
||||
/// cannot read back. The owner is the same user that could ptrace the daemon
|
||||
/// anyway, so granting it read access gives up nothing.
|
||||
const PERM_OWNER_AND_POSSESSOR: u32 = 0x3f3f_0000;
|
||||
|
||||
fn last_error<T>() -> io::Result<T> {
|
||||
Err(io::Error::last_os_error())
|
||||
}
|
||||
|
||||
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(
|
||||
libc::SYS_keyctl,
|
||||
KEYCTL_SEARCH,
|
||||
KEY_SPEC_USER_KEYRING,
|
||||
KEY_TYPE.as_ptr(),
|
||||
description.as_ptr(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
if id >= 0 {
|
||||
return Ok(Some(id));
|
||||
}
|
||||
let err = io::Error::last_os_error();
|
||||
match err.raw_os_error() {
|
||||
// ENOKEY: not stored. EKEYREVOKED/EKEYEXPIRED: stored once, now gone.
|
||||
Some(libc::ENOKEY | libc::EKEYREVOKED | libc::EKEYEXPIRED) => Ok(None),
|
||||
_ => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop this process from being core-dumped or ptraced by an unprivileged
|
||||
/// process of the same user, so the secrets in its memory stay out of crash
|
||||
/// reports. Call once at start of a long-lived process that holds them.
|
||||
pub fn forbid_dumps() {
|
||||
// SAFETY: `PR_SET_DUMPABLE` takes plain integers and only affects this process.
|
||||
let rc = unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0) };
|
||||
if rc != 0 {
|
||||
tracing::warn!(error = %io::Error::last_os_error(), "could not mark the process non-dumpable");
|
||||
}
|
||||
}
|
||||
|
||||
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_add_key,
|
||||
KEY_TYPE.as_ptr(),
|
||||
self.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 _ = 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) => Ok(()),
|
||||
_ => last_error(),
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// The kernel keyring is process-wide state and a sandbox may not offer it,
|
||||
// so one test covers the whole round trip and skips when it is unavailable.
|
||||
#[test]
|
||||
fn stored_secret_round_trips_and_clears() {
|
||||
if PASSPHRASES.store(b"probe").is_err() {
|
||||
eprintln!("kernel keyring unavailable; skipping");
|
||||
return;
|
||||
}
|
||||
let saved = PASSPHRASES
|
||||
.load()
|
||||
.unwrap()
|
||||
.expect("stored secret is readable");
|
||||
assert_eq!(saved.as_slice(), b"probe");
|
||||
|
||||
PASSPHRASES.store(b"replaced").unwrap();
|
||||
assert_eq!(PASSPHRASES.load().unwrap().unwrap().as_slice(), b"replaced");
|
||||
|
||||
PASSPHRASES.clear().unwrap();
|
||||
assert!(PASSPHRASES.load().unwrap().is_none());
|
||||
PASSPHRASES.clear().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub mod config;
|
||||
pub mod control;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod kernelkey;
|
||||
pub mod localindex;
|
||||
pub mod menu;
|
||||
pub mod mounts;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The machine profile that travels with a device (features.md 5.3).
|
||||
//! The machine profile that travels with a device.
|
||||
//!
|
||||
//! Sync folder mappings, pins, and the settings that shape them live in
|
||||
//! `cache.db` and `config.json` — both on the local disk. When that disk dies,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Selective-sync ignore rules for mirror folders (features.md 1.1).
|
||||
//! Selective-sync ignore rules for mirror folders.
|
||||
//!
|
||||
//! A mirror folder otherwise uploads every path beneath its root, which for a
|
||||
//! project directory means `node_modules/`, `.git/`, and `target/` — quota
|
||||
|
||||
@@ -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
|
||||
@@ -143,7 +144,7 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
||||
// create, and re-fetching would replace the size and mtime of the
|
||||
// write we just accepted with the stale revision's — making a file
|
||||
// that was copied in seconds ago read as empty until its upload
|
||||
// lands (offline.md Phase 3).
|
||||
// lands.
|
||||
//
|
||||
// A write that has already drained is the same story one step later:
|
||||
// the drain brought the tree level with the revision it sealed, so
|
||||
@@ -375,7 +376,7 @@ pub(super) async fn run_event_sync(
|
||||
// server head; persist it so the next restart resumes instead of
|
||||
// reseeding (which would skip everything that changed offline).
|
||||
// Seeding needs the network, and this task also runs on mounts that
|
||||
// started offline (offline.md Phase 1) — so retry rather than giving up,
|
||||
// started offline — so retry rather than giving up,
|
||||
// which used to disable live sync for the life of the daemon.
|
||||
Ok(None) => {
|
||||
// Nothing says what changed before this cursor, so a persisted SDK
|
||||
@@ -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;
|
||||
@@ -660,7 +671,7 @@ pub(super) fn run_local_index(
|
||||
scan_local_once(&db, &indexing, &transfers, &mountpoint);
|
||||
}
|
||||
// Interruptible, so teardown joins this thread instead of leaving it to
|
||||
// keep rewriting the index for a mount that is gone (bugs.md B44).
|
||||
// keep rewriting the index for a mount that is gone (`docs/BUGS.md` B44).
|
||||
if !shutdown.sleep(LOCAL_INDEX_CHECK) {
|
||||
return;
|
||||
}
|
||||
@@ -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,8 @@ 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(),
|
||||
pending_uploads: queued.uploads.max(0) as u64 + landing,
|
||||
pending_changes: queued.changes.max(0) as u64,
|
||||
}
|
||||
@@ -369,7 +371,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 { .. }
|
||||
@@ -1297,6 +1299,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
|
||||
@@ -1816,7 +1822,7 @@ pub(crate) fn run_control_socket(
|
||||
info!("control socket listening");
|
||||
for conn in listener.incoming() {
|
||||
// Checked on every accept, including the deliberate poke teardown sends
|
||||
// to wake this thread out of a blocking accept (bugs.md B44).
|
||||
// to wake this thread out of a blocking accept (`docs/BUGS.md` B44).
|
||||
if core.shutdown.is_stopping() {
|
||||
info!("control socket closing");
|
||||
return;
|
||||
|
||||
+187
-18
@@ -1,5 +1,5 @@
|
||||
//! This machine as a Proton Drive Device, and the local folders it keeps in
|
||||
//! step with the remote (devices.md).
|
||||
//! step with the remote.
|
||||
//!
|
||||
//! A sync folder runs in one of two modes. `mirror` keeps a real local tree that
|
||||
//! the engine in [`super::sync`] reconciles both ways; `ondemand` evicts that
|
||||
@@ -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 ----------------------------------------------------------
|
||||
|
||||
@@ -184,8 +201,8 @@ impl Core {
|
||||
}
|
||||
|
||||
/// Adopt an existing device as *this* machine's, pinning it in `config.json`
|
||||
/// so hostname changes and reinstalls stop registering duplicates
|
||||
/// (features.md 5.1). `None` clears the pin and returns to hostname matching.
|
||||
/// so hostname changes and reinstalls stop registering duplicates.
|
||||
/// `None` clears the pin and returns to hostname matching.
|
||||
///
|
||||
/// Refuses a uid that is not in the account: the whole point of the pin is
|
||||
/// that it is authoritative, so accepting a bad one would defer the failure
|
||||
@@ -260,7 +277,108 @@ impl Core {
|
||||
})
|
||||
}
|
||||
|
||||
// ---- device folder sync (devices.md, Phase 1) -------------------------
|
||||
/// 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
|
||||
/// it so restarts reuse the same device. Recovery matches an existing remote
|
||||
@@ -270,8 +388,7 @@ impl Core {
|
||||
/// Resolution order: the *adopted* uid pinned in `config.json`, then the
|
||||
/// cached DB row (validated remotely), then a hostname match, then create.
|
||||
/// The pin exists because the hostname heuristic silently registers a second
|
||||
/// device after a rename or reinstall, orphaning the first one's folders
|
||||
/// (features.md 5.1).
|
||||
/// device after a rename or reinstall, orphaning the first one's folders.
|
||||
pub(crate) fn ensure_device(&self) -> CoreResult<StoredDevice> {
|
||||
let name = this_hostname();
|
||||
// Enumerate the remote devices once: used to validate the pin, validate
|
||||
@@ -361,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
|
||||
@@ -536,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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -617,7 +742,7 @@ impl Core {
|
||||
}
|
||||
|
||||
/// A sibling Core for a secondary FUSE session rooted at an `ondemand` sync
|
||||
/// folder (devices.md Phase 3). It shares everything with this one,
|
||||
/// folder. It shares everything with this one,
|
||||
/// the tree included, except what belongs to a session: its root and its
|
||||
/// kernel channel, both filled in when the session is built.
|
||||
pub(crate) fn fork_state(&self) -> Core {
|
||||
@@ -843,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))
|
||||
})?;
|
||||
@@ -986,8 +1128,7 @@ impl Core {
|
||||
/// restart (their local dirs are empty on disk — the files live in the cloud).
|
||||
/// Best-effort per folder: a missing local path or a failed remote fetch marks
|
||||
/// the folder `error` and moves on rather than aborting the rest. Runs on its
|
||||
/// own thread from `mount` so the network fetches never block startup
|
||||
/// (devices.md Phase 4).
|
||||
/// own thread from `mount` so the network fetches never block startup.
|
||||
pub(crate) fn restore_ondemand_mounts(&self) {
|
||||
let folders = match self.db.sync_folder_list() {
|
||||
Ok(f) => f,
|
||||
@@ -1109,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;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The write-back drain: turning queued mutations into remote calls.
|
||||
//!
|
||||
//! Every write the kernel hands us is answered the moment its bytes and its
|
||||
//! `pending_op` row are on disk (offline.md Phase 3), which is what lets a `cp`
|
||||
//! `pending_op` row are on disk, which is what lets a `cp`
|
||||
//! into the mount run at disk speed and lets an offline write succeed at all.
|
||||
//! This module is the other half: the worker that walks that queue and performs
|
||||
//! the uploads, creates, renames and trashes it recorded.
|
||||
@@ -276,8 +276,7 @@ fn run_authorized_drain(
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Drain the pending-op queue: the background half of every write
|
||||
/// (offline.md Phase 3).
|
||||
/// Drain the pending-op queue: the background half of every write.
|
||||
///
|
||||
/// Runs for the life of the mount. Ops are replayed oldest-first, each
|
||||
/// retried with doubling backoff and *never* dropped on failure — the staged
|
||||
@@ -306,7 +305,7 @@ impl Core {
|
||||
loop {
|
||||
// Between ops, never inside one: an op that has started is either
|
||||
// retired or released, so stopping here cannot leave the queue with
|
||||
// a row claimed by a worker that no longer exists (bugs.md B44).
|
||||
// a row claimed by a worker that no longer exists (`docs/BUGS.md` B44).
|
||||
if self.shutdown.is_stopping() {
|
||||
debug!(primary, "drain worker stopping");
|
||||
return;
|
||||
@@ -348,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;
|
||||
}
|
||||
};
|
||||
@@ -733,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);
|
||||
}
|
||||
|
||||
@@ -1150,7 +1141,7 @@ impl Core {
|
||||
}
|
||||
|
||||
/// Make a node that so far exists only on this machine real, and adopt the
|
||||
/// uid the server gives it (offline.md Phase 3b).
|
||||
/// uid the server gives it.
|
||||
pub(crate) fn drain_local_node(
|
||||
&self,
|
||||
op: &PendingOp,
|
||||
@@ -1850,7 +1841,7 @@ impl Core {
|
||||
/// that window the node can be rewritten by another device, trashed, or
|
||||
/// deleted outright. Sending the blob anyway would silently drop whatever
|
||||
/// happened in between, which is exactly the thing the sync engine refuses
|
||||
/// to do (offline.md Phase 3b).
|
||||
/// to do.
|
||||
///
|
||||
/// Only checkable against a recorded baseline: a write staged before
|
||||
/// [`StagedWrite::based_on`] existed, or one against a node that has never
|
||||
@@ -2009,7 +2000,7 @@ impl Core {
|
||||
self.db.delete_op(op.id)?;
|
||||
// Dropping the pending entry hands the node back to the remote's truth:
|
||||
// reads stop coming from the staged blob, and the event sync stops
|
||||
// skipping it as "ahead of the server" (offline.md Phase 3a). Only if it
|
||||
// skipping it as "ahead of the server". Only if it
|
||||
// is still *this* write's entry — see [`Core::release_pending`].
|
||||
self.release_pending(uid, blob);
|
||||
self.cache.discard_staged(blob);
|
||||
@@ -2679,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 {
|
||||
@@ -2834,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()))
|
||||
@@ -2859,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,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! `Core` used to hold a concrete [`ProtonDriveClient`], so no test could make
|
||||
//! Drive answer late, answer stale, or not answer at all, and the interleavings
|
||||
//! behind most recent bugs could only be found on a real account over a slow
|
||||
//! link (`docs/MILESTONE-3.0.0.md` §2, §8.1). [`DriveApi`] is the part of the
|
||||
//! link. [`DriveApi`] is the part of the
|
||||
//! client the mount, the drain, the event feed and the mirror engine use; the
|
||||
//! real client implements it by forwarding, and the simulation tests implement
|
||||
//! it with an in-memory Drive that injects Drive's known faults.
|
||||
|
||||
@@ -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,8 @@ mod tests {
|
||||
budget: 0,
|
||||
pins: Vec::new(),
|
||||
online: true,
|
||||
session_expired: false,
|
||||
tokens_unsaved: false,
|
||||
pending_uploads: 1,
|
||||
pending_changes: 0,
|
||||
};
|
||||
|
||||
@@ -132,7 +132,7 @@ impl Filesystem for ProtonFs {
|
||||
// A provisional size is the *ciphertext* size, which is larger than the
|
||||
// file. Publishing it makes every reader that trusts `st_size` — rsync,
|
||||
// mmap, sendfile, a sized read loop — run off the end of the file and
|
||||
// fail. So resolve it before answering rather than after (bugs.md B14).
|
||||
// fail. So resolve it before answering rather than after (`docs/BUGS.md` B14).
|
||||
//
|
||||
// The cost is one batched round trip for the whole folder, not one per
|
||||
// file: `ls -l` is one `getattr` per entry and they collapse onto a
|
||||
@@ -172,7 +172,7 @@ impl Filesystem for ProtonFs {
|
||||
/// here — once per `opendir`, which the kernel pairs exactly with one
|
||||
/// `releasedir` — is what makes the offsets stable, so a create or a trash
|
||||
/// during an enumeration can no longer make the caller skip or repeat an
|
||||
/// entry (bugs.md B43).
|
||||
/// entry (`docs/BUGS.md` B43).
|
||||
///
|
||||
/// Enumerating a cold folder is a remote call, so it goes to a worker for
|
||||
/// the same reason `lookup` and `readdir` do.
|
||||
@@ -346,7 +346,7 @@ impl Filesystem for ProtonFs {
|
||||
self.core.workers.run(Lane::Transfer, "read", move || {
|
||||
// The revision disagrees with a provisional size, and B84 fails
|
||||
// such a read outright, so settle the size first. On the network,
|
||||
// hence here and not above (bugs.md B100).
|
||||
// hence here and not above (`docs/BUGS.md` B100).
|
||||
let fsize = match provisional {
|
||||
true => core.resolve_provisional_size(ino.0, &uid).unwrap_or(fsize),
|
||||
false => fsize,
|
||||
@@ -517,7 +517,7 @@ impl Filesystem for ProtonFs {
|
||||
// `> file`. This is the second write path into the API, and
|
||||
// queueing it is what lets a redirect work offline at all: it
|
||||
// never reaches `release`, so without this it failed before any
|
||||
// byte was written (offline.md Phase 2/3b).
|
||||
// byte was written.
|
||||
//
|
||||
// The one branch of `setattr` that can reach the network:
|
||||
// shrinking keeps the prefix, which `queue_truncate` gap-fills
|
||||
@@ -607,7 +607,7 @@ impl Filesystem for ProtonFs {
|
||||
}
|
||||
|
||||
/// `close(2)` calls flush before release. The upload is queued in `release`
|
||||
/// and performed in the background (offline.md Phase 3), so there is nothing
|
||||
/// and performed in the background, so there is nothing
|
||||
/// to push here — the written bytes are already in the scratch file.
|
||||
fn flush(
|
||||
&self,
|
||||
@@ -985,7 +985,7 @@ pub struct ProtonFs {
|
||||
}
|
||||
|
||||
/// How many times a write `open` will re-read its base after a `release`
|
||||
/// published a newer queued revision underneath it (bugs.md B32).
|
||||
/// published a newer queued revision underneath it (`docs/BUGS.md` B32).
|
||||
///
|
||||
/// One retry covers the race, which needs a close and an open of the same file
|
||||
/// to overlap on different threads. More than that is not contention, it is a
|
||||
@@ -1021,7 +1021,7 @@ fn build_listing(st: &State, ino: u64) -> DirListing {
|
||||
///
|
||||
/// The cookie handed back is the index of the *next* entry, so a resumed call
|
||||
/// continues exactly where the last one stopped — which is only true because
|
||||
/// the listing behind those indexes is a snapshot (bugs.md B43).
|
||||
/// the listing behind those indexes is a snapshot (`docs/BUGS.md` B43).
|
||||
fn reply_listing(listing: &DirListing, offset: u64, root: u64, mut reply: ReplyDirectory) {
|
||||
for (i, (ino, is_dir, name)) in listing.iter().enumerate().skip(offset as usize) {
|
||||
let ft = if *is_dir {
|
||||
@@ -1094,7 +1094,7 @@ impl ProtonFs {
|
||||
// `getattr` would, so `ls -l` — which is one `lookup`
|
||||
// per entry and no `getattr` at all — takes its sizes
|
||||
// from here. Resolving only in `getattr` left the whole
|
||||
// listing provisional (bugs.md B14).
|
||||
// listing provisional (`docs/BUGS.md` B14).
|
||||
let provisional = matches!(
|
||||
&e.node.kind,
|
||||
NodeKind::File {
|
||||
@@ -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
|
||||
@@ -1276,7 +1280,7 @@ impl ProtonFs {
|
||||
// revision in between sends this around again. Without the re-check the
|
||||
// new handle carried the *older* base, and its own release published
|
||||
// content that silently discarded the revision closed a moment earlier
|
||||
// (bugs.md B32). A second attempt is already unlikely and a third would
|
||||
// (`docs/BUGS.md` B32). A second attempt is already unlikely and a third would
|
||||
// mean something is republishing continuously, so give up rather than
|
||||
// spin: the caller's `open` fails instead of its data being lost.
|
||||
//
|
||||
@@ -1286,7 +1290,7 @@ impl ProtonFs {
|
||||
for attempt in 0..OPEN_BASE_ATTEMPTS {
|
||||
// The handle starts at the base's size. A provisional one is the
|
||||
// ciphertext size, so the file would grow by the encryption
|
||||
// overhead and reading the base would fail (bugs.md B100). A
|
||||
// overhead and reading the base would fail (`docs/BUGS.md` B100). A
|
||||
// queued revision stamps a real size, so this only goes to the
|
||||
// network for a file the listing has not sized yet.
|
||||
if provisional && self.core.pending_blob(&uid).is_none() {
|
||||
@@ -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
|
||||
@@ -1541,7 +1560,7 @@ impl ProtonFs {
|
||||
let transient = is_transient_name(name);
|
||||
// Offline the server cannot mint a uid, so invent one and queue the
|
||||
// create. The file is real to the caller either way; only its identity is
|
||||
// provisional until the drain (offline.md Phase 3b).
|
||||
// provisional until the drain.
|
||||
//
|
||||
// A parent that is itself still queued forces the same path even when we
|
||||
// are online: the API has no folder to put this in yet.
|
||||
@@ -1652,7 +1671,7 @@ impl ProtonFs {
|
||||
.ok();
|
||||
// As in `create`: offline — or under a parent that is itself still
|
||||
// queued — the folder becomes a placeholder that the drain turns into a
|
||||
// real one (offline.md Phase 3b).
|
||||
// real one.
|
||||
// Losing the network on the way queues it the same way.
|
||||
let minted = if self.core.sends_inline() && !is_local_uid(&parent_uid) {
|
||||
if let Err(error) = self.core.require_uid_writable(&parent_uid) {
|
||||
@@ -1874,7 +1893,7 @@ impl ProtonFs {
|
||||
// rename, so the victim has to be removed first and the operation stops
|
||||
// being atomic. Without this the 422 surfaced as a blanket EIO and every
|
||||
// write-to-temp-then-rename tool — rsync, atomic editor saves — failed
|
||||
// at the very end of its transfer (bugs.md B13).
|
||||
// at the very end of its transfer (`docs/BUGS.md` B13).
|
||||
//
|
||||
// `RENAME_EXCHANGE` has no Proton primitive and cannot be emulated
|
||||
// without a window in which one of the two names does not exist.
|
||||
@@ -1956,8 +1975,7 @@ impl ProtonFs {
|
||||
};
|
||||
// A node whose own creation is still queued has no server-side identity
|
||||
// to rename: the queued op *is* the node, so rewriting its target is the
|
||||
// whole rename. Nothing reaches the API, which is why this works offline
|
||||
// (offline.md Phase 3b).
|
||||
// whole rename. Nothing reaches the API, which is why this works offline.
|
||||
// A create that lands meanwhile leaves no op to rewrite; the node is
|
||||
// then renamed by the uid Drive gave it (docs/BUGS.md B142).
|
||||
if is_local_uid(&uid) {
|
||||
@@ -2156,7 +2174,7 @@ impl ProtonFs {
|
||||
.is_some_and(|e| e.open_count > 0);
|
||||
// A node the server has never heard of cannot be trashed there; deleting
|
||||
// it just means its queued creation is no longer wanted. This works
|
||||
// offline, which the remote path below cannot (offline.md Phase 3b).
|
||||
// offline, which the remote path below cannot.
|
||||
if is_local_uid(&uid) {
|
||||
let dropped = if open_now {
|
||||
self.core.withdraw_queued_ops(&uid)
|
||||
@@ -2185,7 +2203,7 @@ impl ProtonFs {
|
||||
}
|
||||
// Offline: queue it. Trashing is the one mutation a user expects to work
|
||||
// regardless — the file is gone from their point of view the moment the
|
||||
// command returns (offline.md Phase 3b). The same when the network goes
|
||||
// command returns. The same when the network goes
|
||||
// away under the remote call below.
|
||||
let queue_it = |reply: ReplyEmpty| match self.core.queue_trash(&uid, name) {
|
||||
Ok(()) => reply.ok(),
|
||||
@@ -2234,7 +2252,7 @@ impl ProtonFs {
|
||||
self.core.invalidate_trash();
|
||||
// Every other trash site records itself; this one did not, which made a
|
||||
// file found in the trash impossible to attribute after the fact — the
|
||||
// activity log was the only record and it showed nothing (bugs.md B2).
|
||||
// activity log was the only record and it showed nothing (`docs/BUGS.md` B2).
|
||||
self.core
|
||||
.log_activity(ActivityKind::Trash, name, "trashed from the mount", true);
|
||||
reply.ok();
|
||||
|
||||
+40
-36
@@ -123,6 +123,7 @@ use mount::{
|
||||
};
|
||||
use reads::{BlockFlight, BlockRing, PREFETCH_BUDGET, Prefetch, ReaderSlot, STREAM_BYPASS_MIN};
|
||||
use state::{Entry, Intervals, PendingRevision, State, StateGuard, WriteHandle};
|
||||
pub use systemd::idle_sleep;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use transfers::{CountingWriter, JobGuard, OwnedCountingReader, TransferRegistry};
|
||||
use r#virtual::{
|
||||
@@ -226,7 +227,7 @@ const DRAIN_WORKERS: usize = 16;
|
||||
const STAGING_ORPHAN_RETAIN: Duration = Duration::from_secs(30 * 24 * 60 * 60);
|
||||
|
||||
/// First and longest delay between probes for the network coming back after an
|
||||
/// offline mount (offline.md Phase 1). Doubles from min to max: a laptop shut in
|
||||
/// offline mount. Doubles from min to max: a laptop shut in
|
||||
/// a bag is the common case, so the steady state must be cheap, while a brief
|
||||
/// blip should still recover in seconds.
|
||||
const ONLINE_PROBE_MIN: Duration = Duration::from_secs(5);
|
||||
@@ -334,7 +335,7 @@ const MAX_THUMBNAIL_MISSES: usize = 8192;
|
||||
///
|
||||
/// Matches the SDK's own `MAX_BATCH_COUNT`, so a chunk is exactly one request:
|
||||
/// chunking smaller would add round trips, larger would be split anyway and
|
||||
/// delay the waiters this chunking exists to release (bugs.md B14).
|
||||
/// delay the waiters this chunking exists to release (`docs/BUGS.md` B14).
|
||||
const SIZE_UPGRADE_CHUNK: usize = 150;
|
||||
|
||||
/// How many folders may have a size upgrade in flight at once.
|
||||
@@ -344,7 +345,7 @@ const SIZE_UPGRADE_CHUNK: usize = 150;
|
||||
/// both with the number of folders it walked. Eight keeps an interactive `ls -l`
|
||||
/// (one folder, sometimes a couple) entirely unaffected while putting a ceiling
|
||||
/// on the recursive case. A folder past the cap is queued, not dropped: one of
|
||||
/// the running threads takes it over when its own batch ends (bugs.md B100).
|
||||
/// the running threads takes it over when its own batch ends (`docs/BUGS.md` B100).
|
||||
const MAX_SIZE_UPGRADES: usize = 8;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -535,7 +536,7 @@ struct Core {
|
||||
workers: Arc<Workers>,
|
||||
/// Unified SQLite metadata cache: the persistence layer behind the in-memory
|
||||
/// `State` maps. Every mutation writes through here, and the maps rehydrate
|
||||
/// from it on mount (plan.md P1).
|
||||
/// from it on mount.
|
||||
db: Arc<Db>,
|
||||
/// Serializes only shared-list publication and access-loss invalidation.
|
||||
/// Network calls happen before taking it.
|
||||
@@ -545,8 +546,8 @@ struct Core {
|
||||
/// Successful refreshes suppress retries even if persisting their timestamp
|
||||
/// fails. Shared across primary/fork clones and cleared by access events.
|
||||
shared_refresh_deadlines: Arc<Mutex<SharedRefreshDeadlines>>,
|
||||
/// False while the API is unreachable and we are serving the cached tree
|
||||
/// (offline.md Phase 1). Set by the probe thread; read by front-ends through
|
||||
/// False while the API is unreachable and we are serving the cached tree.
|
||||
/// Set by the probe thread; read by front-ends through
|
||||
/// `Response::Status` so the UI can say so rather than leaving the user to
|
||||
/// infer it from a wall of EIO.
|
||||
online: Arc<AtomicBool>,
|
||||
@@ -557,8 +558,8 @@ struct Core {
|
||||
/// [`pause::PAUSED_INDEFINITELY`] for "until resumed", `0` when not paused.
|
||||
/// Persisted, so a pause survives a restart (see [`Core::sync_paused`]).
|
||||
sync_paused_until: Arc<AtomicI64>,
|
||||
/// Writes accepted from the kernel but not yet uploaded, keyed by node
|
||||
/// (offline.md Phase 3). The in-memory face of the `pending_op` table, from
|
||||
/// Writes accepted from the kernel but not yet uploaded, keyed by node.
|
||||
/// The in-memory face of the `pending_op` table, from
|
||||
/// which it is rebuilt on mount.
|
||||
///
|
||||
/// Two things read it: [`Core::read_range`], because until the op drains the
|
||||
@@ -586,7 +587,7 @@ struct Core {
|
||||
/// the queue instead of waiting out their backoff.
|
||||
drain_wake: Arc<(Mutex<bool>, Condvar)>,
|
||||
/// The daemon's stop signal, shared with every background loop this `Core`
|
||||
/// was cloned into so teardown can end and join them (bugs.md B44).
|
||||
/// was cloned into so teardown can end and join them (`docs/BUGS.md` B44).
|
||||
shutdown: Arc<shutdown::Shutdown>,
|
||||
/// How long this node's last upload actually took, in milliseconds, with
|
||||
/// the time it was measured for eviction. Feeds
|
||||
@@ -695,7 +696,7 @@ struct Core {
|
||||
/// Size upgrades currently running, per folder inode, across every mount. A `getattr` that
|
||||
/// needs a real size waits on the entry rather than issuing its own fetch —
|
||||
/// `ls -l` of a folder is one `getattr` per file, and they must collapse
|
||||
/// onto a single batch (bugs.md B14).
|
||||
/// onto a single batch (`docs/BUGS.md` B14).
|
||||
size_upgrades: Arc<Mutex<HashMap<u64, Arc<SizeUpgrade>>>>,
|
||||
/// Replies parked on those upgrades. Shared across every mount for the same
|
||||
/// reason the pool is: the bound that matters is per daemon.
|
||||
@@ -726,11 +727,11 @@ struct Core {
|
||||
/// can say what a pass is doing rather than just "syncing". An entry exists
|
||||
/// only while that folder's reconcile pass is running.
|
||||
sync_progress: Arc<Mutex<HashMap<i64, SyncProgress>>>,
|
||||
/// Channel to the folder-sync engine (devices.md Phase 2): nudges it to
|
||||
/// Channel to the folder-sync engine: nudges it to
|
||||
/// reconcile a folder, reconcile everything, or re-scan its watch set.
|
||||
sync_tx: std::sync::mpsc::Sender<sync::SyncMsg>,
|
||||
/// Secondary FUSE sessions for `ondemand` sync folders, keyed by sync-folder
|
||||
/// id (devices.md Phase 3). Each is a `ProtonFs` rooted at the folder's remote
|
||||
/// id. Each is a `ProtonFs` rooted at the folder's remote
|
||||
/// node, mounted over its local path, sharing this Core's client/cache/db and
|
||||
/// tree but with its own root (`fork_state`). Held so we can unmount on
|
||||
/// toggle back to `mirror` and on daemon shutdown. Each entry retains the
|
||||
@@ -743,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.
|
||||
///
|
||||
@@ -1030,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,
|
||||
@@ -1833,8 +1839,7 @@ impl Core {
|
||||
info!(nodes = st.entries.len(), "hydrated metadata cache from db");
|
||||
}
|
||||
|
||||
/// Rebuild the in-memory pending map from the `pending_op` table on mount
|
||||
/// (offline.md Phase 3).
|
||||
/// Rebuild the in-memory pending map from the `pending_op` table on mount.
|
||||
///
|
||||
/// A queued write survives a restart — that is the point of persisting it —
|
||||
/// so until the drain worker gets to it, reads of that file must still come
|
||||
@@ -2774,7 +2779,7 @@ impl Core {
|
||||
missing.push(uid.clone());
|
||||
}
|
||||
// Returned, not awaited here: the caller is a `getattr` that must not
|
||||
// answer with a provisional size (bugs.md B14), and it parks its reply
|
||||
// answer with a provisional size (`docs/BUGS.md` B14), and it parks its reply
|
||||
// on this batch rather than holding a thread until it lands.
|
||||
self.upgrade_sizes(key, missing)
|
||||
}
|
||||
@@ -2791,7 +2796,7 @@ impl Core {
|
||||
/// `total_size_on_storage`, the *ciphertext* size, which is slightly larger
|
||||
/// than the real one. A read must not plan on it: since B84 a size the
|
||||
/// revision disagrees with fails the read, so `read` settles the size first
|
||||
/// ([`Core::resolve_provisional_size`], bugs.md B100). In `stat` it is a
|
||||
/// ([`Core::resolve_provisional_size`], `docs/BUGS.md` B100). In `stat` it is a
|
||||
/// discrepancy that closes within a round trip, not a repeat of B11 (which
|
||||
/// reported **0** and so suppressed reads entirely).
|
||||
///
|
||||
@@ -2865,11 +2870,11 @@ impl Core {
|
||||
/// Chunked so a waiter is released as soon as *its* file is resolved. A
|
||||
/// single 793-node batch took ~80 s, which outran [`SizeUpgrade::WAIT`] and
|
||||
/// put provisional sizes back in front of callers — the bug this was
|
||||
/// supposed to fix (bugs.md B14).
|
||||
/// supposed to fix (`docs/BUGS.md` B14).
|
||||
///
|
||||
/// When the batch ends, the thread takes over the oldest batch the cap held
|
||||
/// back, so at most [`MAX_SIZE_UPGRADES`] threads run however many folders
|
||||
/// a recursive walk touches (bugs.md B100).
|
||||
/// a recursive walk touches (`docs/BUGS.md` B100).
|
||||
fn run_size_upgrade(&self, key: u64, uids: Vec<NodeUid>, slot: Arc<SizeUpgrade>) {
|
||||
let (mut key, mut uids, mut slot) = (key, uids, slot);
|
||||
loop {
|
||||
@@ -2897,7 +2902,7 @@ impl Core {
|
||||
/// size it is given against the revision's own block table and fails the
|
||||
/// read on a mismatch (B84), so reading on the provisional size is an `EIO`
|
||||
/// every time. `getattr` usually settles the size first, but not when its
|
||||
/// wait times out, so the read cannot rely on it (bugs.md B100).
|
||||
/// wait times out, so the read cannot rely on it (`docs/BUGS.md` B100).
|
||||
fn resolve_provisional_size(&self, ino: u64, uid: &NodeUid) -> Option<u64> {
|
||||
let parent = self.state().entries.get(&ino)?.parent;
|
||||
// Without a network the answer is `None` either way; the bound keeps a
|
||||
@@ -3317,8 +3322,7 @@ impl Core {
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept a released write handle's bytes and queue their upload
|
||||
/// (offline.md Phase 3).
|
||||
/// Accept a released write handle's bytes and queue their upload.
|
||||
///
|
||||
/// This is what makes a copy into the mount run at disk speed: the caller's
|
||||
/// `close` returns once the bytes are staged on local disk and the intent is
|
||||
@@ -3785,8 +3789,8 @@ impl Core {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Invent a node under `parent_uid` and queue the op that will make it real
|
||||
/// (offline.md Phase 3b). Returns the node to intern, exactly as the online
|
||||
/// Invent a node under `parent_uid` and queue the op that will make it real.
|
||||
/// Returns the node to intern, exactly as the online
|
||||
/// path returns the one the server minted.
|
||||
///
|
||||
/// The parent may itself be a placeholder — `mkdir -p` offline, or `cp -r` of
|
||||
@@ -3842,7 +3846,7 @@ impl Core {
|
||||
}
|
||||
|
||||
/// Queue giving a node a new parent and/or name, and apply it to the tree
|
||||
/// now (offline.md Phase 3b).
|
||||
/// now.
|
||||
///
|
||||
/// The op records the desired end state rather than the step, so it both
|
||||
/// supersedes any earlier queued rename and lets the drain skip whichever
|
||||
@@ -3950,7 +3954,7 @@ impl Core {
|
||||
}
|
||||
|
||||
/// Queue trashing a node the server knows about, and drop it from the tree
|
||||
/// now (offline.md Phase 3b).
|
||||
/// now.
|
||||
///
|
||||
/// Anything else queued for this node is discarded first: the user has said
|
||||
/// the file should not exist, so uploading bytes to it or renaming it are
|
||||
@@ -4081,7 +4085,7 @@ impl Core {
|
||||
///
|
||||
/// The path identifies the revision: publication stages a fresh file and
|
||||
/// swaps the map entry to point at it, so an unchanged path means the base a
|
||||
/// caller sampled earlier is still the base now (bugs.md B32).
|
||||
/// caller sampled earlier is still the base now (`docs/BUGS.md` B32).
|
||||
fn pending_blob(&self, uid: &NodeUid) -> Option<PathBuf> {
|
||||
self.pending.lock().get(uid).map(|p| p.path.clone())
|
||||
}
|
||||
@@ -5124,7 +5128,7 @@ impl Core {
|
||||
/// `rename(2)` promises to replace an existing destination atomically. Proton
|
||||
/// offers no such primitive — `rename_node` refuses a name that is already
|
||||
/// taken — so this is the first half of an emulation that is *not* atomic:
|
||||
/// see [`Core::restore_replaced`] for the other half (bugs.md B13).
|
||||
/// see [`Core::restore_replaced`] for the other half (`docs/BUGS.md` B13).
|
||||
///
|
||||
/// A node whose own creation is still queued has never reached the server, so
|
||||
/// dropping its queued ops is the whole removal; nothing goes to the wire and
|
||||
@@ -5950,8 +5954,8 @@ fn parse_node_uid(s: &str) -> Option<NodeUid> {
|
||||
}
|
||||
|
||||
/// The uid a node made on this machine goes by until it lands, so it can be
|
||||
/// interned, listed and written to before the server has ever heard of it
|
||||
/// (offline.md Phase 3b): its row's local id, as [`pdfs_core::db::local_uid`]
|
||||
/// interned, listed and written to before the server has ever heard of it:
|
||||
/// its row's local id, as [`pdfs_core::db::local_uid`]
|
||||
/// has it.
|
||||
fn local_node_uid(lid: i64) -> NodeUid {
|
||||
NodeUid::new(VolumeId::from(LOCAL_VOLUME), LinkId::from(lid.to_string()))
|
||||
@@ -6114,7 +6118,7 @@ fn dir_is_empty(dir: &Path) -> std::io::Result<bool> {
|
||||
|
||||
/// Delete everything inside `dir` but keep `dir` itself (it stays as the FUSE
|
||||
/// mountpoint). Used when a `mirror` folder flips to `ondemand`: the local files
|
||||
/// are the disk we're reclaiming (devices.md Phase 3).
|
||||
/// are the disk we're reclaiming.
|
||||
fn evict_dir_contents(dir: &Path) -> std::io::Result<()> {
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let path = entry?.path();
|
||||
@@ -6196,7 +6200,7 @@ struct Progress {
|
||||
///
|
||||
/// Waiters are released **per chunk**, not once at the end: a waiter only cares
|
||||
/// about its own file, and waiting for the other 792 is what let a large folder
|
||||
/// outrun the timeout (bugs.md B14).
|
||||
/// outrun the timeout (`docs/BUGS.md` B14).
|
||||
#[derive(Default)]
|
||||
struct SizeUpgrade {
|
||||
inner: Mutex<Progress>,
|
||||
@@ -6222,7 +6226,7 @@ enum SizeUpgradeClaim {
|
||||
/// Past the cap this used to return nothing, and the caller answered with the
|
||||
/// provisional size at once. Nothing retried the folder until the next `stat`,
|
||||
/// so a recursive walk over more than [`MAX_SIZE_UPGRADES`] fresh folders showed
|
||||
/// ciphertext sizes for most of them (bugs.md B100). Queued, the batch is still
|
||||
/// ciphertext sizes for most of them (`docs/BUGS.md` B100). Queued, the batch is still
|
||||
/// fetched, and a waiter gets the real size unless [`SizeUpgrade::WAIT`] runs out.
|
||||
fn claim_size_upgrade(
|
||||
in_flight: &mut HashMap<u64, Arc<SizeUpgrade>>,
|
||||
@@ -6425,7 +6429,7 @@ impl SizeWaitQueue {
|
||||
/// Split out from the handler because it is the part that is pure and the part
|
||||
/// that is dangerous: every `Err` here is a refusal that happens *before*
|
||||
/// anything is trashed, and getting one wrong turns a refusal into the
|
||||
/// destruction of the destination (bugs.md B13).
|
||||
/// destruction of the destination (`docs/BUGS.md` B13).
|
||||
///
|
||||
/// `dst_empty` is only meaningful when `dst_dir`; pass `true` otherwise.
|
||||
fn check_replaceable(src_dir: bool, dst_dir: bool, dst_empty: bool) -> Result<(), Errno> {
|
||||
@@ -7155,7 +7159,7 @@ mod size_upgrade_tests {
|
||||
}
|
||||
|
||||
/// Past the cap a folder used to get no upgrade at all, so a recursive
|
||||
/// walk reported ciphertext sizes for most of what it listed (bugs.md B100).
|
||||
/// walk reported ciphertext sizes for most of what it listed (`docs/BUGS.md` B100).
|
||||
#[test]
|
||||
fn a_folder_past_the_cap_is_queued_not_dropped() {
|
||||
let mut in_flight = HashMap::new();
|
||||
@@ -7208,7 +7212,7 @@ mod replace_tests {
|
||||
|
||||
/// The case that motivated all of this: rsync renaming its temp file over
|
||||
/// the real one. Two plain files, and it has to be allowed — refusing is
|
||||
/// what made every rsync transfer fail at the last step (bugs.md B13).
|
||||
/// what made every rsync transfer fail at the last step (`docs/BUGS.md` B13).
|
||||
#[test]
|
||||
fn a_file_may_replace_a_file() {
|
||||
assert!(check_replaceable(false, false, true).is_ok());
|
||||
|
||||
@@ -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) {
|
||||
@@ -248,7 +302,7 @@ impl Core {
|
||||
}
|
||||
|
||||
/// Watch the link for the life of the mount: sleep while it is up, and
|
||||
/// probe it back while it is down (offline.md Phase 1).
|
||||
/// probe it back while it is down.
|
||||
///
|
||||
/// A mount that started from the cache is down from the first moment; one
|
||||
/// that started online goes down when [`Core::mark_offline`] says so.
|
||||
@@ -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
|
||||
@@ -193,7 +197,7 @@ pub(super) fn clear_stale_mount(mountpoint: &Path) {
|
||||
}
|
||||
|
||||
/// `sync_state` key holding the uid of the My Files root, so a later run can
|
||||
/// recover it from `nodes` without the API (offline.md Phase 1).
|
||||
/// recover it from `nodes` without the API.
|
||||
const ROOT_UID_KEY: &str = "root_uid";
|
||||
|
||||
/// The My Files root, and whether we got it from the API (`true`) or from the
|
||||
@@ -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}")))?;
|
||||
@@ -401,7 +436,7 @@ pub(crate) fn mount_with(
|
||||
.all_share_access()
|
||||
.map_err(|error| std::io::Error::other(format!("load shared access: {error}")))?;
|
||||
|
||||
// The folder-sync engine (devices.md Phase 2) runs on its own thread and is
|
||||
// The folder-sync engine runs on its own thread and is
|
||||
// nudged over this channel; the sender lives in Core so control-socket
|
||||
// handlers can trigger reconciles.
|
||||
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
|
||||
@@ -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
|
||||
@@ -501,7 +537,7 @@ pub(crate) fn mount_with(
|
||||
// Every long-lived thread started from here on is kept in `workers` and
|
||||
// joined at the end of this function. Leaving them running turned an
|
||||
// in-process remount into a second generation of drain workers, sync engines
|
||||
// and sweeps all operating on a mount that no longer exists (bugs.md B44).
|
||||
// and sweeps all operating on a mount that no longer exists (`docs/BUGS.md` B44).
|
||||
let mut workers: Vec<std::thread::JoinHandle<()>> = Vec::new();
|
||||
for worker in 0..DRAIN_WORKERS {
|
||||
let core = core.clone();
|
||||
@@ -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");
|
||||
@@ -738,7 +786,7 @@ pub(crate) fn mount_with(
|
||||
};
|
||||
|
||||
// Unmount every on-demand sync folder too, or the kernel mounts linger as
|
||||
// stale and the next start fails with EBUSY (devices.md Phase 3).
|
||||
// stale and the next start fails with EBUSY.
|
||||
if let Some(worker) = restore_worker
|
||||
&& worker.join().is_err()
|
||||
{
|
||||
@@ -798,7 +846,7 @@ const WORKER_JOIN_DEADLINE: Duration = Duration::from_secs(10);
|
||||
/// The mount used to return with its drain workers, sync engine, sweep, online
|
||||
/// probe, indexer and control listener all still running on `Core` clones. The
|
||||
/// process usually exited straight afterwards and hid it; an in-process remount
|
||||
/// did not, and started a second full set on top of the first (bugs.md B44).
|
||||
/// did not, and started a second full set on top of the first (`docs/BUGS.md` B44).
|
||||
///
|
||||
/// Two of the waits need waking rather than merely flagging: the drain workers
|
||||
/// block on their own condvar (`wake_drain`), and the control listener blocks
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Reading and writing this machine's `profile.json` in its device root, and
|
||||
//! the restore that rebuilds a machine from one (features.md 5.2 and 5.3).
|
||||
//! the restore that rebuilds a machine from one.
|
||||
//!
|
||||
//! The document itself is defined in [`pdfs_core::profile`]; this is the half
|
||||
//! that talks to Drive. Saving is best-effort and never blocks a user action:
|
||||
@@ -246,7 +246,7 @@ impl Core {
|
||||
Profile::parse(&buf).map(Some).map_err(CoreError::invalid)
|
||||
}
|
||||
|
||||
// ---- restore (features.md 5.2) ----------------------------------------
|
||||
// ---- restore ----------------------------------------
|
||||
|
||||
/// The folders under a device that could be synced here, each carrying a
|
||||
/// proposed local path. `device` names another registered device by uid;
|
||||
|
||||
@@ -464,7 +464,7 @@ impl Core {
|
||||
/// it, and the [`BLOCK_SIZE`] assumption when it has not — which is every
|
||||
/// first read, because learning it means opening a `RevisionReader`, and
|
||||
/// opening one per read is precisely the per-file key derivation the block
|
||||
/// cache exists to avoid (bugs.md B12, B85).
|
||||
/// cache exists to avoid (`docs/BUGS.md` B12, B85).
|
||||
///
|
||||
/// Being wrong here is not a correctness problem: `read_at` plans over the
|
||||
/// real sizes and clamps to what was asked for either way. It costs a
|
||||
@@ -493,13 +493,13 @@ impl Core {
|
||||
) -> Result<Vec<u8>, Errno> {
|
||||
// A queued write has not reached the remote yet, so the remote's current
|
||||
// revision is stale and the staged blob is the truth. Serve from it until
|
||||
// the drain worker lands the upload (offline.md Phase 3).
|
||||
// the drain worker lands the upload.
|
||||
if let Some(pending) = self.pending.lock().get(uid).cloned() {
|
||||
return self.read_pending(&pending, offset, len);
|
||||
}
|
||||
// A node created offline and never written has no blob and no remote: it
|
||||
// is an empty file, and asking the API about a `local~` uid would only
|
||||
// earn a 404 (offline.md Phase 3b). Unless its create landed since the
|
||||
// earn a 404. Unless its create landed since the
|
||||
// read looked: the landing moved the queued write to the uid Drive
|
||||
// gave it, and the row says which (`docs/BUGS.md` B179).
|
||||
if is_local_uid(uid) {
|
||||
@@ -636,7 +636,7 @@ impl Core {
|
||||
///
|
||||
/// A block that comes back shorter than the file's size says it must be is
|
||||
/// never served, cached or promoted: it is repaired from the whole-file
|
||||
/// download, or the read fails. See [`Core::repair_block`] (bugs.md B84).
|
||||
/// download, or the read fails. See [`Core::repair_block`] (`docs/BUGS.md` B84).
|
||||
async fn read_block(
|
||||
&self,
|
||||
reader: &Arc<dyn RevisionRead>,
|
||||
@@ -651,7 +651,7 @@ impl Core {
|
||||
// file's boundaries are wrong, and the failure is not confined to short
|
||||
// reads: sizes that *overstate* a block shift every block after it and
|
||||
// return full-length reads of the wrong bytes. So a disagreement of any
|
||||
// kind takes the file off the range path entirely (bugs.md B84).
|
||||
// kind takes the file off the range path entirely (`docs/BUGS.md` B84).
|
||||
let bytes = if reader.size() != fsize {
|
||||
warn!(
|
||||
%uid, bidx = span.idx, fsize,
|
||||
@@ -664,7 +664,7 @@ impl Core {
|
||||
// Learned here rather than on a path of its own: this is where a
|
||||
// reader is open anyway, and recording the real geometry is what
|
||||
// lets the *next* read plan its blocks on the revision's own
|
||||
// boundaries instead of the 4 MiB fallback (bugs.md B85).
|
||||
// boundaries instead of the 4 MiB fallback (`docs/BUGS.md` B85).
|
||||
if cache_blocks {
|
||||
self.cache
|
||||
.store_block_geometry(uid, mtime, fsize, reader.block_sizes());
|
||||
@@ -706,7 +706,7 @@ impl Core {
|
||||
/// The range path derives every block boundary from the revision's recorded
|
||||
/// block sizes. When those understate the file, reads come back short with
|
||||
/// no error anywhere, and the caller hands userspace a truncated file that
|
||||
/// looks like a clean EOF (bugs.md B84). `download_file_to` does not do that
|
||||
/// looks like a clean EOF (`docs/BUGS.md` B84). `download_file_to` does not do that
|
||||
/// arithmetic — it streams the block list — so it returns the whole file
|
||||
/// where the range path cannot, which is why pinning was the workaround.
|
||||
/// This does the same thing on the user's behalf, once, and the resulting
|
||||
@@ -918,7 +918,7 @@ impl Core {
|
||||
let end = offset.saturating_add(len).min(fsize);
|
||||
let mut out = Vec::with_capacity((end - offset) as usize);
|
||||
// On the revision's own boundaries where they are known, and on the
|
||||
// 4 MiB fallback where they are not (bugs.md B85).
|
||||
// 4 MiB fallback where they are not (`docs/BUGS.md` B85).
|
||||
let wanted = self.block_geometry(uid, mtime, fsize).spans(offset, end);
|
||||
let (Some(&head), Some(&tail)) = (wanted.first(), wanted.last()) else {
|
||||
return Ok(out);
|
||||
@@ -1038,7 +1038,7 @@ impl Core {
|
||||
// — the cache validates it on read, the network path in `read_block`
|
||||
// — so this refuses rather than quietly contributing fewer bytes
|
||||
// than the range covers, which is what turned a bad block into a
|
||||
// truncated file (bugs.md B84).
|
||||
// truncated file (`docs/BUGS.md` B84).
|
||||
if block.len() as u64 != span.len {
|
||||
error!(
|
||||
%uid, bidx = span.idx, len = block.len(), expected = span.len, fsize,
|
||||
|
||||
@@ -660,6 +660,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 +684,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(),
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! invisible; in an in-process remount — which is how the daemon recovers from a
|
||||
//! mount going away underneath it — it leaks a whole generation of threads that
|
||||
//! keep writing to the database and calling the API on behalf of a mount that no
|
||||
//! longer exists (bugs.md B44).
|
||||
//! longer exists (`docs/BUGS.md` B44).
|
||||
//!
|
||||
//! The primitive is deliberately small: a flag plus a condvar. Every loop that
|
||||
//! would otherwise call `thread::sleep` calls [`Shutdown::sleep`] instead, which
|
||||
|
||||
@@ -451,6 +451,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() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! An in-memory Drive for the simulation tests (`docs/MILESTONE-3.0.0.md` §8.1).
|
||||
//! An in-memory Drive for the simulation tests (`docs/TESTING.md`).
|
||||
//!
|
||||
//! [`FakeDrive`] is the server: one tree of nodes with uids, revisions and
|
||||
//! names, and an event log. Each simulated daemon reaches it through its own
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The simulation tests of `docs/MILESTONE-3.0.0.md` §8: a fake Drive with
|
||||
//! The simulation tests of `docs/TESTING.md`: a fake Drive with
|
||||
//! the faults the real one has, and the tools to drive daemons against it.
|
||||
|
||||
pub(crate) mod daemon;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The reference file system of the simulation (`docs/MILESTONE-3.0.0.md`
|
||||
//! §8.2, property 4): what each syscall a simulated client makes should
|
||||
//! The reference file system of the simulation (`docs/TESTING.md`,
|
||||
//! "Simulation runs", property 1): what each syscall a simulated client makes should
|
||||
//! answer, and the tree it should leave, on a POSIX file system.
|
||||
//!
|
||||
//! A [`Model`] covers one client's own folder; paths are relative to it. The
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The simulation runs of `docs/MILESTONE-3.0.0.md` §8.2.
|
||||
//! The simulation runs described in `docs/TESTING.md`.
|
||||
//!
|
||||
//! A run starts one or more daemons on a shared [`FakeDrive`], each owning a
|
||||
//! folder `c<n>` that only it writes, so there is one writer per file. A seed
|
||||
|
||||
@@ -183,7 +183,7 @@ impl WriteHandle {
|
||||
}
|
||||
}
|
||||
|
||||
/// A released write whose upload has not happened yet (offline.md Phase 3).
|
||||
/// A released write whose upload has not happened yet.
|
||||
///
|
||||
/// The bytes live in the content cache's staging dir and the intent lives in the
|
||||
/// `pending_op` table; this pairs them in memory so a read can be served without
|
||||
@@ -253,7 +253,7 @@ pub(crate) struct State {
|
||||
/// two pages shifted every later entry, and the caller silently skipped or
|
||||
/// repeated whatever crossed the page boundary. Each entry is
|
||||
/// `(ino, is_dir, name)` — the kernel type is mapped at reply time so this
|
||||
/// module stays free of `fuser` (bugs.md B43).
|
||||
/// module stays free of `fuser` (`docs/BUGS.md` B43).
|
||||
pub(crate) dir_snapshots: HashMap<u64, Arc<DirListing>>,
|
||||
/// Resident inode attributes whose effective access changed during an
|
||||
/// intern/root refresh. Core drains this set and notifies the matching
|
||||
@@ -1198,7 +1198,7 @@ impl State {
|
||||
/// re-enumerate, which is the cheaper way to stay honest about what the
|
||||
/// server did. A queued rename cannot: re-enumerating needs the network, and
|
||||
/// the server has not been told yet in any case — so this *is* the tree's
|
||||
/// new truth until the op drains (offline.md Phase 3b).
|
||||
/// new truth until the op drains.
|
||||
pub(crate) fn rename_in_place(
|
||||
&mut self,
|
||||
ino: u64,
|
||||
@@ -1317,7 +1317,7 @@ impl State {
|
||||
/// created offline, nothing at all), so this row is the only record that the
|
||||
/// file is as long as the caller was told it is. Without it a restart serves
|
||||
/// the stale size and the file reads as truncated — or empty — while its
|
||||
/// bytes sit safely in staging (offline.md Phase 3).
|
||||
/// bytes sit safely in staging.
|
||||
/// Returns the node to persist rather than queueing it in the outbox, and is
|
||||
/// the one mutation here whose write-through the caller must do itself:
|
||||
/// failure has to be reported, not logged. Dropping the row silently means
|
||||
|
||||
@@ -68,7 +68,7 @@ impl Core {
|
||||
/// docs for the identical/divergent policy.
|
||||
pub(crate) fn run_conflict_sweep_loop(&self) {
|
||||
// Both waits are interruptible, so teardown does not have to sit out a
|
||||
// five-minute interval to join this thread (bugs.md B44).
|
||||
// five-minute interval to join this thread (`docs/BUGS.md` B44).
|
||||
if !self.shutdown.sleep(CONFLICT_SWEEP_WARMUP) {
|
||||
return;
|
||||
}
|
||||
|
||||
+579
-110
@@ -1,4 +1,4 @@
|
||||
//! Two-way folder sync engine (devices.md Phase 2).
|
||||
//! Two-way folder sync engine.
|
||||
//!
|
||||
//! Each `mirror`-mode synced folder is kept mirrored between a local directory
|
||||
//! and a folder under this machine's Proton Drive device. A local filesystem
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -1342,7 +1391,7 @@ impl Core {
|
||||
/// rename below is the destructive step, and the download in between can run
|
||||
/// for minutes, so the destination is re-examined right before it: a local
|
||||
/// file that no longer matches the plan is set aside as a conflict copy
|
||||
/// instead of being replaced (bugs.md B39).
|
||||
/// instead of being replaced (`docs/BUGS.md` B39).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn download_file(
|
||||
&self,
|
||||
@@ -1441,7 +1490,7 @@ impl Core {
|
||||
/// the pre-upload identity is what makes the next pass see the path as
|
||||
/// locally changed and upload a whole, clean revision over it. Recording the
|
||||
/// post-upload stat instead declared the torn revision synchronized and left
|
||||
/// it as the file's content on Drive (bugs.md B40).
|
||||
/// it as the file's content on Drive (`docs/BUGS.md` B40).
|
||||
async fn record_file_baseline(
|
||||
&self,
|
||||
folder_id: i64,
|
||||
@@ -1505,7 +1554,7 @@ fn open_to_upload(path: &Path, rel: &str) -> Result<Option<std::fs::File>, Strin
|
||||
/// records the identity the upload *read* rather than the one on disk now, so
|
||||
/// the next pass classifies the path as locally changed and replaces the torn
|
||||
/// revision. Without the log there is nothing in the journal to explain the
|
||||
/// extra revision (bugs.md B40).
|
||||
/// extra revision (`docs/BUGS.md` B40).
|
||||
fn warn_if_torn(rel: &str, path: &Path, streamed: LocalSig) {
|
||||
let now = local_sig(path);
|
||||
if !now.is_some_and(|now| now.same_content(&streamed)) {
|
||||
@@ -1537,7 +1586,7 @@ fn local_sig(path: &Path) -> Option<LocalSig> {
|
||||
/// transfer started, and a download of a large file can run for minutes. Without
|
||||
/// this, a local edit completed inside that window was replaced by the remote
|
||||
/// version with no copy of it kept anywhere — the one shape of loss a sync
|
||||
/// engine must not have (bugs.md B39). Keeping the copy makes it re-upload as a
|
||||
/// engine must not have (`docs/BUGS.md` B39). Keeping the copy makes it re-upload as a
|
||||
/// new file on the next pass, which is the same resolution a both-sides-changed
|
||||
/// conflict already gets.
|
||||
///
|
||||
@@ -1573,7 +1622,7 @@ fn keep_racing_local_edit(path: &Path, expected: Option<LocalSig>) -> std::io::R
|
||||
/// exists, so the same removal was re-attempted and re-reported as an error on
|
||||
/// every pass, forever. Every other error is still an error, and keeping the
|
||||
/// baseline is exactly right there: the surviving file must not be mistaken for
|
||||
/// a new local creation on the next pass and uploaded back (bugs.md B42).
|
||||
/// a new local creation on the next pass and uploaded back (`docs/BUGS.md` B42).
|
||||
fn removed_locally(result: std::io::Result<()>) -> std::io::Result<()> {
|
||||
match result {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1830,7 +2004,7 @@ fn system_mtime(meta: &std::fs::Metadata) -> i64 {
|
||||
///
|
||||
/// Whole seconds cannot distinguish an edit made in the same second as the last
|
||||
/// sync from no edit at all, and if the size did not change either, that file
|
||||
/// was never uploaded (bugs.md B25). `None` for a clock or filesystem that
|
||||
/// was never uploaded (`docs/BUGS.md` B25). `None` for a clock or filesystem that
|
||||
/// cannot answer, which falls the comparison back to seconds rather than
|
||||
/// asserting an mtime of zero.
|
||||
fn system_mtime_ns(meta: &std::fs::Metadata) -> Option<i64> {
|
||||
@@ -1845,7 +2019,7 @@ mod tests {
|
||||
|
||||
/// A removal of something already gone has produced the state the sync
|
||||
/// engine wanted, so its baseline is dropped rather than retried forever.
|
||||
/// Anything else stays an error and keeps the baseline (bugs.md B42).
|
||||
/// Anything else stays an error and keeps the baseline (`docs/BUGS.md` B42).
|
||||
#[test]
|
||||
fn an_already_absent_path_counts_as_removed() {
|
||||
let root = std::env::temp_dir().join(format!("pdfs-b42-{}", std::process::id()));
|
||||
@@ -1862,7 +2036,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The download guard: a destination that still matches the plan is
|
||||
/// replaced silently, one that has moved on since is kept (bugs.md B39).
|
||||
/// replaced silently, one that has moved on since is kept (`docs/BUGS.md` B39).
|
||||
#[test]
|
||||
fn a_download_keeps_a_local_edit_it_did_not_plan_for() {
|
||||
let root = std::env::temp_dir().join(format!("pdfs-b39-{}", std::process::id()));
|
||||
@@ -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,14 +2378,22 @@ 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());
|
||||
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
/// A restored folder (features.md 5.2) starts with an empty baseline against
|
||||
/// A restored folder starts with an empty baseline against
|
||||
/// an empty local directory and a full remote. That must reconcile as
|
||||
/// "download everything" rather than tripping the wipe guard — otherwise
|
||||
/// every restore would wedge its folder on the first pass.
|
||||
@@ -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();
|
||||
|
||||
@@ -33,7 +33,7 @@ pub(crate) enum SyncMsg {
|
||||
/// Re-read the folder set and adjust the filesystem watches (after add/remove).
|
||||
Rewatch,
|
||||
/// The daemon is tearing down: finish the current pass and end the engine
|
||||
/// thread, so the mount can join it (bugs.md B44).
|
||||
/// thread, so the mount can join it (`docs/BUGS.md` B44).
|
||||
Stop,
|
||||
}
|
||||
|
||||
@@ -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 ------------------------------------------------------------
|
||||
@@ -20,7 +20,7 @@ impl LocalSig {
|
||||
/// Nanoseconds decide it when both sides have them; otherwise the comparison
|
||||
/// falls back to whole seconds, which is what a baseline row written before
|
||||
/// schema 28 — or a filesystem that reports no sub-second time — can support
|
||||
/// (bugs.md B25).
|
||||
/// (`docs/BUGS.md` B25).
|
||||
pub(super) fn same_content(&self, other: &Self) -> bool {
|
||||
if self.size != other.size {
|
||||
return false;
|
||||
@@ -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 {
|
||||
@@ -161,7 +166,7 @@ pub(super) enum Pending {
|
||||
/// was there. A download can run for minutes, so the apply step revalidates
|
||||
/// against it immediately before it renames over the destination: a writer
|
||||
/// that finished an edit in the meantime must not be overwritten without a
|
||||
/// conflict copy (bugs.md B39).
|
||||
/// conflict copy (`docs/BUGS.md` B39).
|
||||
Download {
|
||||
rel: String,
|
||||
uid: NodeUid,
|
||||
|
||||
@@ -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
|
||||
@@ -218,7 +308,7 @@ pub(super) fn plan_file(
|
||||
}
|
||||
|
||||
let local_changed = local.is_some_and(|item| {
|
||||
// Nanoseconds where both sides have them (bugs.md B25): whole seconds
|
||||
// Nanoseconds where both sides have them (`docs/BUGS.md` B25): whole seconds
|
||||
// could not see an edit made in the same second as the last sync that
|
||||
// left the file the same length, so it was never uploaded.
|
||||
baseline.is_none_or(|base| !LocalSig::from(item).same_content(&LocalSig::from(base)))
|
||||
@@ -291,7 +381,7 @@ mod file_plan_tests {
|
||||
|
||||
/// Whole seconds cannot see an edit made inside the same second that leaves the
|
||||
/// file the same length, so a mirror silently stopped uploading it. With a
|
||||
/// nanosecond time on both sides it is an ordinary local change (bugs.md B25).
|
||||
/// nanosecond time on both sides it is an ordinary local change (`docs/BUGS.md` B25).
|
||||
#[test]
|
||||
fn a_same_second_same_size_edit_is_still_a_local_change() {
|
||||
let mut base = baseline(10, 20, 30, 40);
|
||||
|
||||
@@ -74,6 +74,26 @@ pub(crate) fn watchdog_ping() {
|
||||
let _ = notify("WATCHDOG=1\n");
|
||||
}
|
||||
|
||||
/// Sleep for `duration`, pinging the watchdog on the way.
|
||||
///
|
||||
/// For the stretches where the daemon is alive but has no mount, and so no
|
||||
/// supervisor: waiting for a login, or backing off after a failed mount. The
|
||||
/// manager arms the watchdog when the process starts, so without these pings a
|
||||
/// daemon that is merely retrying is aborted after `WatchdogSec` and leaves a
|
||||
/// coredump instead of the error it was logging. Never use this while mounted:
|
||||
/// there the ping must prove the control socket answers.
|
||||
pub fn idle_sleep(duration: Duration) {
|
||||
const CHUNK: Duration = Duration::from_secs(10);
|
||||
let mut left = duration;
|
||||
watchdog_ping();
|
||||
while !left.is_zero() {
|
||||
let step = left.min(CHUNK);
|
||||
std::thread::sleep(step);
|
||||
left -= step;
|
||||
watchdog_ping();
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the manager the daemon is stopping, so a slow teardown is not mistaken
|
||||
/// for a hang by the watchdog it is racing.
|
||||
pub(crate) fn stopping() {
|
||||
|
||||
@@ -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::*;
|
||||
@@ -119,6 +120,9 @@ struct Ui {
|
||||
/// keyring (a DBus round-trip). Populated at startup and updated only on
|
||||
/// login / logout. `None` = signed out.
|
||||
session: RefCell<Option<auth::StoredSession>>,
|
||||
/// Whether this run already asked for the mailbox password of a locked
|
||||
/// account, so a refresh never re-opens a dialog the user cancelled.
|
||||
unlock_offered: Cell<bool>,
|
||||
/// Whether the last refresh saw a live mount daemon. Gates the unpin buttons
|
||||
/// (which need the daemon to evict + re-hydrate) and every mutating action.
|
||||
mounted: RefCell<bool>,
|
||||
@@ -199,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.
|
||||
@@ -300,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();
|
||||
@@ -362,6 +379,7 @@ fn build_window(app: &adw::Application) {
|
||||
busy: Cell::new(0),
|
||||
opening: RefCell::new(HashSet::new()),
|
||||
session: RefCell::new(auth::load().ok()),
|
||||
unlock_offered: Cell::new(false),
|
||||
mounted: RefCell::new(false),
|
||||
sidebar: sidebar_list.clone(),
|
||||
nav: split.clone(),
|
||||
@@ -561,6 +579,7 @@ 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(),
|
||||
},
|
||||
queue: QueueState {
|
||||
group: locations_widgets.queue_group.clone(),
|
||||
@@ -813,7 +832,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)
|
||||
@@ -1136,25 +1154,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();
|
||||
@@ -1206,7 +1206,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")
|
||||
@@ -1291,6 +1291,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");
|
||||
@@ -1346,8 +1352,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(),
|
||||
|
||||
@@ -832,8 +832,8 @@ fn show_location_column(ui: &Rc<Ui>, visible: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu for one entry, in the order of `docs/UI_UX_PLAN.md` §6: open,
|
||||
/// offline, sharing, organising, and the destructive item last on its own.
|
||||
/// The menu for one entry, in a fixed order: open, offline, sharing,
|
||||
/// organising, and the destructive item last on its own.
|
||||
pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||
let mut menu = ActionMenu::new();
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
@@ -1220,9 +1220,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 +1235,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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! `~/ProtonDrive` mount, plus each folder this computer backs up, whether it is
|
||||
//! a mirrored local directory or an on-demand FUSE session. The page is called
|
||||
//! *Locations* rather than *Mounts* because a mirror folder is a plain directory
|
||||
//! with no FUSE session behind it (mount-architecture.md §4).
|
||||
//! with no FUSE session behind it.
|
||||
//!
|
||||
//! The device rows drive the same control requests the Computers page used to:
|
||||
//! mode switch, sync now, remove. What is new here is that the primary mount is
|
||||
@@ -46,6 +46,8 @@ 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,
|
||||
}
|
||||
|
||||
/// What a folder row's controls are built from. The subtitle and progress bar
|
||||
@@ -108,6 +110,7 @@ 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) pause: adw::SplitButton,
|
||||
pub(crate) queue_group: adw::PreferencesGroup,
|
||||
pub(crate) retry_all: gtk4::Button,
|
||||
@@ -134,7 +137,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) = build_sync_card();
|
||||
let pause = build_pause_button();
|
||||
let (queue_group, retry_all) = build_queue_group();
|
||||
let conflicts_group = adw::PreferencesGroup::builder()
|
||||
@@ -253,6 +256,7 @@ pub(crate) fn build_locations_page(history: >k4::Widget) -> (gtk4::Widget, Loc
|
||||
card_icon,
|
||||
card_title,
|
||||
card_detail,
|
||||
card_sign_in,
|
||||
pause,
|
||||
queue_group,
|
||||
retry_all,
|
||||
@@ -290,6 +294,11 @@ 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_retry = ui.clone();
|
||||
ui.locations
|
||||
.queue
|
||||
@@ -379,9 +388,15 @@ 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,
|
||||
) {
|
||||
let icon = gtk4::Image::builder()
|
||||
.icon_name("pdfs-sync-symbolic")
|
||||
.pixel_size(48)
|
||||
@@ -408,9 +423,17 @@ 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");
|
||||
card.append(&icon);
|
||||
card.append(&text);
|
||||
(card, icon, title, detail)
|
||||
card.append(&sign_in);
|
||||
(card, icon, title, detail, sign_in)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -128,7 +128,12 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
|
||||
set_signing_in(&ui, true);
|
||||
ui.login.login_status.set_text(&gettext("Signing in…"));
|
||||
let (rx, totp_req_rx, hv_req_rx) = spawn_login(username, password);
|
||||
let LoginChannels {
|
||||
result: rx,
|
||||
totp: totp_req_rx,
|
||||
hv: hv_req_rx,
|
||||
mailbox: mailbox_req_rx,
|
||||
} = spawn_login(username, password);
|
||||
|
||||
// Surface the 2FA dialog only if the SDK actually asks for a code (i.e.
|
||||
// the account has 2FA enabled). The worker blocks until the dialog feeds
|
||||
@@ -145,15 +150,40 @@ 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),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// And for the mailbox password, which only a two-password account has.
|
||||
// The worker asks again after a wrong answer, so this loops too.
|
||||
let ui_mailbox = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
while let Ok((attempt, answer_tx)) = mailbox_req_rx.recv().await {
|
||||
ask_mailbox(&ui_mailbox, attempt > 0, move |answer| {
|
||||
// A cancelled dialog drops the sender, which the worker
|
||||
// reads as a cancelled login.
|
||||
if let Some(answer) = answer {
|
||||
let _ = answer_tx.send(answer);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -209,6 +239,12 @@ pub(crate) fn login_error_message(error: &pdfs_core::Error) -> String {
|
||||
pdfs_core::Error::Other(message) if message.contains("two-factor") => {
|
||||
gettext("Sign-in cancelled: no two-factor code was entered.")
|
||||
}
|
||||
pdfs_core::Error::Other(message) if message.contains("mailbox") => {
|
||||
gettext("Sign-in cancelled: no mailbox password was entered.")
|
||||
}
|
||||
pdfs_core::Error::WrongMailboxPassword => {
|
||||
gettext("That mailbox password didn't unlock your account.")
|
||||
}
|
||||
pdfs_core::Error::Other(message) if message.contains("verification") => gettext(
|
||||
"Sign-in cancelled: the verification wasn't completed. Sign in again to get a new one.",
|
||||
),
|
||||
@@ -220,26 +256,34 @@ pub(crate) fn login_error_message(error: &pdfs_core::Error) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the login worker asks the UI for, and how it reports back.
|
||||
pub(crate) struct LoginChannels {
|
||||
/// The final outcome, once.
|
||||
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 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)>)>,
|
||||
}
|
||||
|
||||
/// Run the async SRP + optional 2FA login on a dedicated current-thread Tokio
|
||||
/// runtime. Returns two channels: the first yields the final login result once;
|
||||
/// the second fires *only if* the account needs a 2FA code, carrying a
|
||||
/// [`std::sync::mpsc::Sender`] the UI uses to feed the code back. The login
|
||||
/// closure blocks the worker on that sender until the dialog answers, so the
|
||||
/// code is requested lazily and can't expire before the password proof.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(crate) fn spawn_login(
|
||||
username: String,
|
||||
password: String,
|
||||
) -> (
|
||||
async_channel::Receiver<Result<(), String>>,
|
||||
async_channel::Receiver<std::sync::mpsc::Sender<String>>,
|
||||
async_channel::Receiver<(String, std::sync::mpsc::Sender<String>)>,
|
||||
) {
|
||||
/// runtime. The returned [`LoginChannels`] carry the final result and the lazy
|
||||
/// prompts. Each login closure blocks the worker on a sender the UI answers, so
|
||||
/// a code is requested lazily and can't expire before the password proof.
|
||||
pub(crate) fn spawn_login(username: String, password: String) -> LoginChannels {
|
||||
let (tx, rx) = async_channel::bounded(1);
|
||||
// Two, not one: a CAPTCHA-gated login restarts, so a 2FA account is asked
|
||||
// for a code on each attempt and a single slot would deadlock the worker.
|
||||
let (totp_req_tx, totp_req_rx) = async_channel::bounded(2);
|
||||
let (hv_req_tx, hv_req_rx) = async_channel::bounded(1);
|
||||
let (mailbox_req_tx, mailbox_req_rx) = async_channel::bounded(1);
|
||||
std::thread::spawn(move || {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
@@ -267,19 +311,30 @@ pub(crate) fn spawn_login(
|
||||
.recv()
|
||||
.map_err(|_| pdfs_core::Error::Other("two-factor entry cancelled".into()))
|
||||
},
|
||||
|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
|
||||
|attempt| {
|
||||
let (answer_tx, answer_rx) = std::sync::mpsc::channel::<(String, bool)>();
|
||||
mailbox_req_tx
|
||||
.send_blocking((attempt, answer_tx))
|
||||
.map_err(|_| pdfs_core::Error::Other("mailbox prompt closed".into()))?;
|
||||
let (password, remember) = answer_rx
|
||||
.recv()
|
||||
.map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))?;
|
||||
Ok(HumanVerificationCredential::captcha(token))
|
||||
.map_err(|_| pdfs_core::Error::Other("mailbox entry cancelled".into()))?;
|
||||
Ok(auth::Mailbox {
|
||||
password: password.into(),
|
||||
remember,
|
||||
})
|
||||
},
|
||||
|hv| {
|
||||
// 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()))
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -287,7 +342,143 @@ pub(crate) fn spawn_login(
|
||||
});
|
||||
let _ = tx.send_blocking(result);
|
||||
});
|
||||
(rx, totp_req_rx, hv_req_rx)
|
||||
LoginChannels {
|
||||
result: rx,
|
||||
totp: totp_req_rx,
|
||||
hv: hv_req_rx,
|
||||
mailbox: mailbox_req_rx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask for the mailbox password of a two-password account.
|
||||
///
|
||||
/// `done` gets the password and the "keep unlocked" choice, or `None` when the
|
||||
/// dialog was cancelled. `retry` says the last answer was wrong. The choice
|
||||
/// defaults to off: storing the mailbox unlock in the keyring trades away what
|
||||
/// a separate mailbox password is for, so it is something to opt into.
|
||||
pub(crate) fn ask_mailbox(
|
||||
ui: &Rc<Ui>,
|
||||
retry: bool,
|
||||
done: impl FnOnce(Option<(String, bool)>) + 'static,
|
||||
) {
|
||||
let body = if retry {
|
||||
gettext("That mailbox password didn't unlock your account. Try again.")
|
||||
} else {
|
||||
gettext("This account has a separate mailbox password. Enter it to unlock your files.")
|
||||
};
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Mailbox Password"))
|
||||
.body(body)
|
||||
.build();
|
||||
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let entry = adw::PasswordEntryRow::builder()
|
||||
.title(gettext("Mailbox password"))
|
||||
.activates_default(true)
|
||||
.build();
|
||||
let remember = adw::SwitchRow::builder()
|
||||
.title(gettext("Keep unlocked on this computer"))
|
||||
.subtitle(gettext(
|
||||
"Stores the unlock in the system keyring, so Proton Drive opens without asking after every login. Otherwise you enter the password once per session.",
|
||||
))
|
||||
.build();
|
||||
group.add(&entry);
|
||||
group.add(&remember);
|
||||
dialog.set_extra_child(Some(&group));
|
||||
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("unlock", &gettext("Unlock"));
|
||||
dialog.set_response_appearance("unlock", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("unlock"));
|
||||
dialog.set_close_response("cancel");
|
||||
|
||||
let done = RefCell::new(Some(done));
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
let Some(done) = done.borrow_mut().take() else {
|
||||
return;
|
||||
};
|
||||
let password = entry.text().to_string();
|
||||
if resp == "unlock" && !password.is_empty() {
|
||||
done(Some((password, remember.is_active())));
|
||||
} else {
|
||||
done(None);
|
||||
}
|
||||
});
|
||||
|
||||
let parent = ui_window(ui).map(|w| w.upcast::<gtk4::Window>());
|
||||
dialog.present(parent.as_ref());
|
||||
}
|
||||
|
||||
/// If the signed-in account is locked, ask for its mailbox password — once per
|
||||
/// run; after a cancel, a toast keeps an Unlock button within reach.
|
||||
///
|
||||
/// The key state is a keyring and kernel-keyring read, so it runs on a worker.
|
||||
pub(crate) fn offer_unlock(ui: &Rc<Ui>) {
|
||||
if ui.unlock_offered.get() {
|
||||
return;
|
||||
}
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let state = gio::spawn_blocking(|| auth::key_state().ok()).await;
|
||||
if matches!(state, Ok(Some(auth::KeyState::Locked))) && !ui.unlock_offered.replace(true) {
|
||||
prompt_unlock(&ui, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
enum UnlockOutcome {
|
||||
Unlocked,
|
||||
Wrong,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Ask for the mailbox password and unlock the account with it.
|
||||
fn prompt_unlock(ui: &Rc<Ui>, retry: bool) {
|
||||
let ui_done = ui.clone();
|
||||
ask_mailbox(ui, retry, move |answer| {
|
||||
let Some((password, remember)) = answer else {
|
||||
toast_action(
|
||||
&ui_done,
|
||||
&gettext("Proton Drive is locked. Enter your mailbox password to unlock it."),
|
||||
&gettext("Unlock"),
|
||||
|ui| prompt_unlock(ui, false),
|
||||
);
|
||||
return;
|
||||
};
|
||||
glib::spawn_future_local(async move {
|
||||
// Verifying talks to Proton and the restart talks to systemd, so
|
||||
// both run on a worker, never on the main loop.
|
||||
let outcome = gio::spawn_blocking(move || {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => return UnlockOutcome::Failed(e.to_string()),
|
||||
};
|
||||
match rt.block_on(auth::unlock(&password, remember)) {
|
||||
Ok(()) => {
|
||||
service::restart();
|
||||
UnlockOutcome::Unlocked
|
||||
}
|
||||
Err(pdfs_core::Error::WrongMailboxPassword) => UnlockOutcome::Wrong,
|
||||
Err(e) => UnlockOutcome::Failed(login_error_message(&e)),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
match outcome {
|
||||
Ok(UnlockOutcome::Unlocked) => {
|
||||
toast(&ui_done, &gettext("Unlocked."));
|
||||
refresh(&ui_done);
|
||||
}
|
||||
Ok(UnlockOutcome::Wrong) => prompt_unlock(&ui_done, true),
|
||||
Ok(UnlockOutcome::Failed(message)) => {
|
||||
toast_error(&ui_done, &gettext("Couldn't unlock"), &message)
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Show the lazy two-factor dialog and feed the entered code back to the waiting
|
||||
@@ -330,6 +521,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.
|
||||
@@ -374,6 +622,12 @@ mod tests {
|
||||
assert!(login_error_message(&hv).contains("verification wasn't completed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_mailbox_prompt_is_reported_as_such() {
|
||||
let error = pdfs_core::Error::Other("mailbox entry cancelled".into());
|
||||
assert!(login_error_message(&error).contains("no mailbox password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unmapped_error_keeps_its_text() {
|
||||
let error = pdfs_core::Error::Other("something odd".into());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -860,6 +878,7 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
|
||||
}
|
||||
}
|
||||
|
||||
offer_unlock(ui);
|
||||
refresh_status(ui);
|
||||
refresh_transfers(ui);
|
||||
}
|
||||
@@ -1209,6 +1228,8 @@ pub(crate) fn paint_status(ui: &Rc<Ui>, status: Response) {
|
||||
failing_error,
|
||||
paused,
|
||||
paused_until,
|
||||
session_expired,
|
||||
tokens_unsaved,
|
||||
..
|
||||
} = status
|
||||
else {
|
||||
@@ -1219,7 +1240,11 @@ 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 paused {
|
||||
SyncState::Paused {
|
||||
until: paused_until,
|
||||
queued,
|
||||
@@ -1282,6 +1307,14 @@ 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 user paused syncing; nothing goes up until it resumes. Outranks
|
||||
/// everything else, because it is the reason for everything else.
|
||||
Paused {
|
||||
@@ -1316,7 +1349,29 @@ 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 (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::Paused { until, queued } => (
|
||||
"media-playback-pause-symbolic",
|
||||
Some("warning"),
|
||||
@@ -1387,6 +1442,7 @@ 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.status.status_title.set_label(&title);
|
||||
ui.status.status_detail.set_visible(detail.is_some());
|
||||
ui.status
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -128,20 +128,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,13 +127,33 @@ 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
|
||||
/// from Shared or Shared-with-me may not: it can live under a device folder's
|
||||
/// own inode space, or on someone else's volume, neither of which the primary
|
||||
/// mount's path space can name. Those carry a uid instead, and the daemon
|
||||
/// resolves it with `Core::resolve_anywhere` (mount-architecture.md P4).
|
||||
/// resolves it with `Core::resolve_anywhere`.
|
||||
///
|
||||
/// The two are separate request variants rather than one request with an
|
||||
/// optional field on purpose: an older daemon that ignored `uid` would resolve
|
||||
@@ -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;
|
||||
|
||||
+139
-23
@@ -45,6 +45,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 +62,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",
|
||||
}
|
||||
}
|
||||
@@ -88,8 +91,9 @@ struct DriveState {
|
||||
/// 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 +147,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),
|
||||
@@ -166,6 +170,15 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||
// No daemon: describe login state instead so the menu is still useful.
|
||||
Err(_) => {
|
||||
let (line, phase) = match auth::load() {
|
||||
Ok(s) if matches!(auth::key_state(), Ok(auth::KeyState::Locked)) => (
|
||||
// Translators: {username} is the Proton account name. The
|
||||
// account has a mailbox password that has to be entered.
|
||||
gettext_f(
|
||||
"Signed in as {username} — locked",
|
||||
&[("username", &s.username)],
|
||||
),
|
||||
Phase::Attention,
|
||||
),
|
||||
Ok(s) => (
|
||||
// Translators: {username} is the Proton account name.
|
||||
gettext_f(
|
||||
@@ -208,9 +221,14 @@ fn unexpected_reply(default_mountpoint: &Path) -> DriveState {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -219,6 +237,8 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
|
||||
pending_changes,
|
||||
failing_ops,
|
||||
paused,
|
||||
session_expired,
|
||||
tokens_unsaved,
|
||||
..
|
||||
} = status
|
||||
else {
|
||||
@@ -226,14 +246,25 @@ 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 phase = if *session_expired {
|
||||
Phase::SessionExpired
|
||||
} else if *tokens_unsaved {
|
||||
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"),
|
||||
_ if paused => gettext("Sync paused"),
|
||||
_ if failing_ops > 0 => ngettext_f(
|
||||
"{n} change needs attention",
|
||||
@@ -269,11 +300,19 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -342,7 +381,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,
|
||||
}
|
||||
}
|
||||
@@ -400,6 +439,9 @@ 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(&[]))),
|
||||
}
|
||||
|
||||
@@ -476,6 +518,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() {
|
||||
@@ -534,11 +580,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),
|
||||
};
|
||||
@@ -583,6 +625,80 @@ mod tests {
|
||||
assert_eq!(phase_of(false, 0, true, false), Phase::Synced);
|
||||
}
|
||||
|
||||
fn status(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{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");
|
||||
}
|
||||
|
||||
#[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",
|
||||
|
||||
+536
-468
File diff suppressed because it is too large.
Load diff
+3984
-3184
File diff suppressed because it is too large.
Load diff
@@ -11,6 +11,123 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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))
|
||||
|
||||
## [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.
|
||||
|
||||
### Added
|
||||
- **Two-password accounts.** An account with a separate mailbox password now signs in: the app
|
||||
and `pdfs login` ask for it after the login password and check it against the account, so a
|
||||
typo is a retry and not a daemon that never mounts. The mailbox password is never stored.
|
||||
What it unlocks is kept in kernel memory for the login session, so it is asked for once per
|
||||
session and is gone after a reboot or when you log out of the machine.
|
||||
- **`pdfs unlock` and `pdfs lock`.** `pdfs unlock` enters the mailbox password of a locked
|
||||
account and starts the daemon's mount; `pdfs lock` forgets it again. `--remember` on `login`
|
||||
and `unlock` (and the "Keep unlocked on this computer" switch in the app) keeps the unlock in
|
||||
the system keyring instead, so the daemon starts without asking; `pdfs lock --forget` removes
|
||||
it. Off by default: it gives up the separation a second password is for.
|
||||
- **Locked state.** `pdfs status`, `pdfs diagnose` and the tray say when the account is waiting
|
||||
for its mailbox password, and the app asks for it when it opens.
|
||||
|
||||
### Changed
|
||||
- **The keyring no longer holds a password.** The session blob keeps the keys' passphrases
|
||||
instead of the mailbox password, and a blob written by 3.0.x is converted on the first start.
|
||||
A stored password the account rejects (a two-password account that was signed in with only
|
||||
its login password) leaves the account locked instead of failing every start.
|
||||
- **The daemon is not dumpable.** It holds the unlock in memory, so core dumps and same-user
|
||||
`ptrace` are off for it.
|
||||
- **The offline cache is re-keyed** from the new secret, so the first start after the update
|
||||
reads it as cold and fetches the tree again once.
|
||||
|
||||
### Fixed
|
||||
- **systemd killed a daemon that was only retrying.** `WatchdogSec` was answered only once a
|
||||
mount existed, so a daemon waiting for a login or retrying a failed mount was aborted after
|
||||
two minutes and restarted in a loop, hiding the error it logged. It now answers the watchdog
|
||||
while it waits. ([#26](https://github.com/narrrl/proton-drive-linux/issues/26))
|
||||
- **A refreshed token could overwrite a newer unlock or revive a signed-out session.** Token
|
||||
rotation now updates only the tokens in the stored blob.
|
||||
|
||||
## [3.0.0] — 2026-10-05
|
||||
|
||||
Schema: **42**. The queue table is rebuilt so an id is never given out twice; queued changes
|
||||
@@ -1599,6 +1716,14 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
|
||||
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
||||
by a new POSIX compliance suite for the filesystem.
|
||||
|
||||
[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
|
||||
|
||||
+2
-1
@@ -83,7 +83,7 @@ commands are for scripts and for cases where the mount is not available.
|
||||
| `pdfs sync limit [--up RATE] [--down RATE]` | Cap bandwidth, for example `500K` or `2M` per second. `0` removes a cap. Without options, prints the current caps. |
|
||||
| `pdfs sync queue` | List changes that have not reached Proton Drive yet. |
|
||||
| `pdfs sync issues` | List queued changes Proton Drive refused or that keep failing, and what to do. |
|
||||
| `pdfs sync export ID DEST` | Save a copy of a queued upload's content. The upload stays queued. |
|
||||
| `pdfs sync export ID DEST` | Save a copy of a queued upload's content. `DEST` may be a folder; an existing file is never overwritten. The upload stays queued. |
|
||||
| `pdfs sync discard ID [--yes]` | Drop a queued change and undo it here: a new file or folder goes, anything else goes back to what Proton Drive has. Content not exported first is lost. |
|
||||
| `pdfs sync retry [ID]` | Retry one queued operation, or every failed one. |
|
||||
| `pdfs sync restore [--device UID] [--yes]` | Re-attach this computer's backed-up folders to local folders and download them. `--device` restores another computer's folders. See [RECOVERY.md](RECOVERY.md). |
|
||||
@@ -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
|
||||
|
||||
|
||||
+13
-6
@@ -38,7 +38,7 @@ Inside `content/`:
|
||||
> not reached Proton Drive and exists nowhere else.
|
||||
|
||||
Everything in these directories except the keyring is **decrypted**. See
|
||||
[ARCHITECTURE.md §8](ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||
[ARCHITECTURE.md §10](ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||
|
||||
## `config.json`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -190,13 +194,16 @@ Command-line flags win over the file: `--gtk`, `--dmenu`, `--fzf` and `--menu`.
|
||||
| `PDFS_LOCALEDIR` | app, tray, prompt | Load translations from another directory (development) |
|
||||
| `PDFS_STAND_IN_WIDGETS` | app | `1` uses the libadwaita 1.5 fallback widgets on newer systems (development) |
|
||||
|
||||
The acceptance suite has its own `PDFS_ACCEPTANCE_*` variables, listed in [TESTING.md](TESTING.md).
|
||||
The tests have their own variables (`PDFS_ACCEPTANCE_*`, `PDFS_SIM_*`, `PDFS_MIGRATE_DB`),
|
||||
listed in [TESTING.md](TESTING.md).
|
||||
|
||||
## The systemd service
|
||||
|
||||
`proton-drive.service` is a user unit. It restarts the service whenever it exits, restarts it when
|
||||
it stops answering for two minutes (`WatchdogSec=120`), and caps its memory at 6 GiB. To change
|
||||
the unit without editing the packaged file, use a drop-in:
|
||||
`proton-drive.service` is a user unit started with your graphical session
|
||||
(`WantedBy=graphical-session.target`). It restarts the service whenever it exits, restarts it
|
||||
when it stops answering for two minutes (`WatchdogSec=120`), throttles it above 2 GiB of memory
|
||||
(`MemoryHigh`) and stops it at 6 GiB (`MemoryMax`). To change the unit without editing the
|
||||
packaged file, use a drop-in:
|
||||
|
||||
```bash
|
||||
systemctl --user edit proton-drive.service
|
||||
|
||||
+16
-6
@@ -31,12 +31,12 @@ time when the installed library has them (`crates/pdfs-gui/src/compat.rs`).
|
||||
| Path | Contents |
|
||||
|---|---|
|
||||
| `crates/pdfs-core` | Shared library: authentication and keyring (`auth`), configuration (`config`), SQLite schema and queries (`db/`), content cache (`cache`), control protocol (`control`), search scoring, sync ignore rules, Google Takeout parsing, machine profile |
|
||||
| `crates/pdfs-fuse` | The daemon: FUSE filesystem, read path (`reads`), write queue (`queue`, `drain`, `upload`), sync engine (`sync/`), remote events (`background`), push events for front ends (`events`), control handlers (`control`), photos, devices, supervisor |
|
||||
| `crates/pdfs-fuse` | The daemon: FUSE handlers (`filesystem`), the tree (`state`), reads (`reads`), the queue and the drain (`queue`, `drain`, `upload`), link state (`link`), the sync engine (`sync`, `sync/`), moves between locations (`relocate`), remote events (`background`), push events (`events`), control handlers (`control`), photos, devices, the supervisor, and the simulation tests (`sim/`) |
|
||||
| `crates/pdfs-cli` | The `pdfs` binary |
|
||||
| `crates/pdfs-gui` | `pdfs-app`, `pdfs-tray` and `pdfs-prompt`. Pages in `src/app/pages/`, shared widgets in `src/app/widgets/`, CSS and icons in `resources/` |
|
||||
| `po/` | Translation template, catalogs and the scripts that maintain them |
|
||||
| `packaging/` | systemd unit, desktop files, icon, PKGBUILD, RPM spec, AUR recipes |
|
||||
| `scripts/` | FUSE acceptance suite, map-data generators |
|
||||
| `scripts/` | FUSE acceptance suite, map-data generators, the wiki publisher |
|
||||
| `docs/` | This documentation |
|
||||
|
||||
The GUI talks to the daemon only through the control socket. Anything a front end needs that the
|
||||
@@ -87,8 +87,9 @@ cargo test --workspace --locked
|
||||
scripts/fuse-acceptance.sh --offline-only
|
||||
```
|
||||
|
||||
For changes to the FUSE layer, the write queue or the sync engine, also run the live acceptance
|
||||
cases described in [TESTING.md](TESTING.md).
|
||||
For changes to the FUSE layer, the queue or the sync engine, also run the
|
||||
[simulation runs](TESTING.md#simulation-runs), which CI runs only after a push to `main`, and the
|
||||
[acceptance suite](TESTING.md#acceptance-suite) on a test account.
|
||||
|
||||
For changes to GUI strings, check that every catalog is complete:
|
||||
|
||||
@@ -136,6 +137,15 @@ file to `po/POTFILES.in`, run `po/update.sh`, and translate the new entries in e
|
||||
`#[serde(default)]`, so older files still load, and document it in
|
||||
[CONFIGURATION.md](CONFIGURATION.md).
|
||||
|
||||
**A bug fix.** Add or update the entry in [BUGS.md](BUGS.md#adding-an-entry), cover the fix with a
|
||||
unit test, a simulation scenario or a `regression B<n>` acceptance case, and cite the entry in the
|
||||
code as `docs/BUGS.md B<n>`. Do not cite planning notes that are not in the repository.
|
||||
|
||||
**A user-visible change.** Update the matching page (user guide, CLI reference, configuration)
|
||||
and the `[Unreleased]` section of [CHANGELOG.md](CHANGELOG.md). `scripts/publish-wiki.sh` copies
|
||||
the six user pages (`INSTALL`, `USER_GUIDE`, `CLI`, `CONFIGURATION`, `TROUBLESHOOTING`, `RECOVERY`)
|
||||
to the GitHub wiki, so keep their file names.
|
||||
|
||||
## Releasing
|
||||
|
||||
Releases are cut from `main` by pushing a `v*` tag. `.github/workflows/release.yml` then:
|
||||
@@ -160,5 +170,5 @@ git tag vX.Y.Z
|
||||
git push origin main vX.Y.Z
|
||||
```
|
||||
|
||||
Before a release that touches the filesystem or sync, run the managed live mode matrix from
|
||||
[TESTING.md](TESTING.md). Open release-assurance items are listed in [ROADMAP.md](ROADMAP.md).
|
||||
Before a release, work through [Before a release](TESTING.md#before-a-release). Open
|
||||
release-assurance items are listed in [ROADMAP.md](ROADMAP.md#release-assurance).
|
||||
@@ -1,182 +0,0 @@
|
||||
# 3.0.0: what is done and what is left
|
||||
|
||||
Tracks `docs/MILESTONE-3.0.0.md`. Update it with every commit that moves a phase.
|
||||
|
||||
## Done
|
||||
|
||||
- **Phase 0**: fake Drive, simulation runs, CI job (2.9.0).
|
||||
- **Phase 1**: queued-op preconditions and own sealed revisions in the database (2.9.0).
|
||||
- **Phase 2**: local ids, inode = local id, one tree for every mount (2.10.0).
|
||||
- **Phase 3, first part**: mkdir, create, rename, unlink and rmdir on the mount are recorded
|
||||
locally and sent from the queue; `"local_first": false` brings back the old path (5e0f30c).
|
||||
- Fixes from the simulation and account runs since then: B125, B127, B130 and B141 to B193.
|
||||
- **Sync issues, first cut** (§6.2): refusals sorted into issues, shown at once in the app,
|
||||
tray and CLI; Export button and `pdfs sync export`; `pdfs sync issues`; Discard button and
|
||||
`pdfs sync discard`; a warning badge on the node in the file browser.
|
||||
|
||||
## Phase 3: left for 3.0.0
|
||||
|
||||
1. **Sync issues (§6.2)**: first cut done.
|
||||
- [x] The drain sorts a refusal from Drive into an issue kind (quota, access, missing,
|
||||
limit, rejected) and stores it on the queued op (schema 38).
|
||||
- [x] An op with an issue counts as "needs attention" at once, not after six failures.
|
||||
- [x] The queue listing carries the issue kind and whether its content can be exported.
|
||||
- [x] Export a queued file's content (control request, `pdfs sync export`).
|
||||
- [x] `pdfs sync issues`, and the issue shown in `pdfs sync queue`.
|
||||
- [x] The app's Sync page shows each issue with what the user can do; Export and Retry
|
||||
buttons; translated in every catalog.
|
||||
- [x] A name collision lands under a conflict name (create, rename, move, re-homed create)
|
||||
and goes in the activity log, as does a queued edit kept as a conflict copy. The app
|
||||
words each one in the user's language (the last one since B181).
|
||||
- [x] Discard an issue on purpose (after export, or for a change with no content), and
|
||||
undo the local change it stood for: a node made here goes, a node Drive has goes back
|
||||
to what Drive has. Control request, `pdfs sync discard`, Discard button with a
|
||||
confirmation; translated in every catalog.
|
||||
- [x] Show the issue on the node itself: a folder listing carries each node's issue, and
|
||||
the file browser shows a warning badge with what it means. The listing is published
|
||||
again when the set of issues changes.
|
||||
2. **Planner and executor (§5.4, §5.5)**: ops keyed by local id; remove `mint_local_uid`,
|
||||
`finish_create` and `adopt_real_uid`; a landing create sets `remote_uid` on its row.
|
||||
- [x] Queued ops carry `lid` and `parent_lid` (schema 39). The claim reads the parent's uid
|
||||
through `parent_lid`, and a landing folder rewrites the ops inside it by local id.
|
||||
- [x] Node rows link to their parent by lid (`nodes.parent_lid`, schema 40), and a restart
|
||||
places each node under its parent's row as it is now.
|
||||
- [x] Queries over children, subtrees and ancestors by `parent_lid`, and no rewrite of the
|
||||
children's `parent_uid` when a folder lands (R3), nor of the ops made inside it (R2).
|
||||
- [x] No more placeholders: a node made here goes by its local id until it lands. In four
|
||||
parts:
|
||||
- [x] 6a: a node made here goes by `local~<lid>`, a function of its row, instead of a
|
||||
minted `local~<ms>-<n>`; schema 41 rewrites the old placeholders (nodes, ops, pins).
|
||||
- [x] 6b: an op is found by its node's local id under either uid, the claim keeps one op
|
||||
per local id on the wire, and the drain sends the uid the row has now;
|
||||
`remap_local_uid` is gone.
|
||||
- [x] 6c: children, subtrees, ancestors and ops' parents by lid, so a landing create
|
||||
rewrites no other row or op; what is read for a uid names the parent as its row has it
|
||||
now. The row keeps its stand-in in `nodes.uid` rather than `NULL`: it is as stable,
|
||||
and `NULL` would have changed some 40 node queries for no change in behaviour. Pins
|
||||
stay keyed by uid and move with the row in the landing transaction (B148).
|
||||
- [x] 6d: a node written under its stand-in after it landed updates its landed row
|
||||
instead of bringing the placeholder row back, so a write that finds its create
|
||||
landed takes the uid from the row and no longer polls the tree for up to 2 s
|
||||
(`landed_uid` is gone). In memory, `pending`, the uploads and the tree stay keyed by
|
||||
the uid Drive knows: ops are matched by lid in the database (6b), `hidden` and
|
||||
`creating` only ever hold remote uids, and `landed_row_uid` is the one place at the
|
||||
mount's edge where a stand-in a handle still holds becomes the real uid.
|
||||
- [x] An executor that runs ops in dependency order, many at once: 16 drain workers instead
|
||||
of 3, and a worker that lands an op wakes the idle ones, which slept through the ops it
|
||||
made claimable. The claim already keeps the order: a create waits for its parent's
|
||||
uid, a node's ops go one at a time. Ops stay blocking calls on worker threads: each
|
||||
one spends its time waiting on Drive, so tasks would buy nothing threads do not.
|
||||
3. **Speed (§7)**: run independent ops in parallel, batch trashes and moves; measure 1,000 files
|
||||
and 100 folders on Wi-Fi against LAN (target: within 10 %).
|
||||
- [x] The measurement: `a_thousand_files_drain_as_fast_on_wifi_as_on_lan` in `sim/daemon.rs`,
|
||||
run with `PDFS_SIM_MEASURE=1`. It prints the times and asserts nothing yet.
|
||||
- [x] Baseline on eb1c105, three drain threads: the syscalls take 3.7 s on LAN and 4.3 s on
|
||||
Wi-Fi; the drain takes 138 s on LAN and 938 s on Wi-Fi, 6.8 times as long.
|
||||
- [x] Sixteen workers: the syscalls take 5.2 s on LAN and 5.0 s on Wi-Fi, which meets the
|
||||
target; the drain takes 47 s on LAN and 247 s on Wi-Fi. Most of a create on Wi-Fi is
|
||||
its read-back waiting for Drive to list the new node (250 ms, then 1 s), so batching
|
||||
(step 8) helps trashes and moves, not creates.
|
||||
- [x] Batched trashes and moves: left for after 3.0.0 (see below). The target is met
|
||||
without them, the drain waits on the creates' read-back, and splitting one answer into
|
||||
many results is where the B15x to B17x races lived.
|
||||
4. **Done-when checks**:
|
||||
- [ ] Simulation passes on the Wi-Fi profile: `one_client_on_a_slow_link` failed in CI on
|
||||
seeds 2 and 3. Two bugs (B127, B151) and a harness gap: a settle did not read the log, so
|
||||
it could not explain a known bug's damage. All three fixed; not yet confirmed in CI.
|
||||
Replays then found two races on the LAN profile too (B152, B153), both fixed. CI on
|
||||
7269cb5 failed LAN seed 9, a regression from the B153 fix (B154), and Wi-Fi seed 2,
|
||||
a write whose answer was lost (B155). Both fixed. One slow-link run of seed 2 failed
|
||||
once with a rename answering `ENOENT` as a folder move landed. Not reproduced since;
|
||||
it may be B152. Replays of slow-link seed 4 failed two of six times on a file written
|
||||
again after its create lost its answer (B156, fixed). With sixteen drain workers
|
||||
(16fca10), seed 4 failed every replay on a write after an upload that was not read
|
||||
back (B157, fixed). Seed 3 then failed four of ten runs on two renames of one file
|
||||
landing out of order (B158, fixed). Flaky-link seed 4 failed one of ten runs on a
|
||||
lookup that answered `EAGAIN` while the root was listed again after a restart (B159,
|
||||
fixed). Slow-link seed 4 also had folders reach Drive 30 s late, after the
|
||||
drain's idle poll (B160, fixed). Slow-link seed 3 failed on a file moved while its
|
||||
create was on the wire, which the mount showed under its old name (B161, fixed).
|
||||
Seed 4 failed on a folder removed and made again, whose create adopted the removed
|
||||
folder (B162, fixed). CI on 903452a passed every profile. CI on f9b1638 failed
|
||||
slow-link seed 2: a step that unlinks an open file, appends and reads it back hung
|
||||
for 150 s. The B130 matcher did not fire, and CI kept no stacks; one replay passed.
|
||||
It may be a kernel notice sent from a runtime worker by another path than B130's,
|
||||
which a68dae9 (not yet pushed then) also takes off the runtime. CI now keeps a hung
|
||||
run's stacks.
|
||||
- [x] Known-bug list in `sim/run.rs` is empty: the run on 812eb3d (`PDFS_SIM_KNOWN=fail`,
|
||||
twelve seeds) passed every profile, so the matchers are gone. That confirms B125 to
|
||||
B133; B134 stays open with `local_first` off only, which the runs do not mount. The confirmation run (`PDFS_SIM_KNOWN=fail`)
|
||||
on da013c2 failed `one_client_on_a_flaky_link` seeds 6 and 11 on two new bugs, both
|
||||
after restarts: a write over a create never read back (B182) and a trash that forgot
|
||||
what its create sent (B183). Replays with those fixed found a third: a create renamed
|
||||
after it lost its answer (B184). All three fixed; no known-bug matcher fired. It was
|
||||
stopped before `three_clients`. The rerun on 3073052 found a create that waited out
|
||||
a retry after a write replaced its blob (B185, fixed), and a file deleted offline
|
||||
while open that lost the bytes its handle read (B186, fixed; older than 3.0.0).
|
||||
The run on 2baad67 found that a restart before such a file's trash went out queued
|
||||
its kept bytes again, for good (B188, fixed), and changes that sat out their retry
|
||||
after an outage only the drain had met (B189, fixed; older than 3.0.0), and a file
|
||||
saved just before a stop whose release the stop dropped (B190, fixed; older too). The run
|
||||
on 1436c5f found a file deleted after its failed create was written again left on Drive
|
||||
(B191, fixed). Its test for B193 found a new empty file waiting out the idle poll (B192,
|
||||
fixed), and seed 6 a deleted file's trash taking a file of the user's renamed to the name
|
||||
its create was sent under (B193, fixed).
|
||||
- [ ] A clean account run. The one on a6f36d5 (2026-10-04, LAN: 427 passed, 12 failed)
|
||||
failed B113 and B121 on every on-demand mount, two mirror-to-mirror move cases, and
|
||||
the journal check. B121 was a bug (B149, fixed); B113's case still expected creates
|
||||
to reach Drive at once and now accepts queued ones. The move cases were the harness:
|
||||
it retried every source after one was busy, and expected a refusal where a mirror to
|
||||
mirror move carries the local copy along. The journal showed a conflict copy retrying
|
||||
into a deleted folder (B150, fixed). Not confirmed yet. The run on 2.10.0
|
||||
(2026-10-04: 260 passed, 3 failed) failed a new file that read back empty (B163,
|
||||
fixed), a 128-entry folder that listed 129 (B164, fixed), and its cleanup, which ran
|
||||
while the daemon was restarting: the stop waited on a busy thread until systemd killed
|
||||
it (B165, bounded). The next run on the working tree (2026-10-04: 259 passed, 4
|
||||
failed) found a wide folder dropping files whose create landed while it was listed
|
||||
(B166, fixed), or listing one empty: the new file that read back empty again
|
||||
(B166 too). Its cleanup found no control socket because it ran 1.5 s after the
|
||||
last restart, before the new daemon had bound it: `pdfs status` reports that as not
|
||||
running, which the run's queue wait takes for an empty queue (the script's side).
|
||||
Looking into it found the start spending 1.7 s rebuilding listings (B167, fixed).
|
||||
Its journal check caught a file removed with its folder while uploading being
|
||||
sent again, to the root (B168, fixed), and a stop that let a mirror pass go on
|
||||
until the runtime ended under it (B169, fixed). CI on 9bbc994 failed
|
||||
`one_client_on_a_good_link`: a write opened as an upload landed took the revision
|
||||
it replaced as its base, and the next write failed with `EIO` (B170, fixed). The
|
||||
suite's seed 9 found a deleted file's trash waiting out its create's backoff (B171,
|
||||
fixed). Run three at a time on 2502af6, seed 8 failed two of nine runs: a file renamed
|
||||
while its upload was read back showed its old name again (B172, fixed). The loop
|
||||
on d850265 failed seed 12 once in nine runs: a file deleted just as its create
|
||||
landed stayed on Drive (B173, fixed). The suite on 378667f failed seed 6: a rename
|
||||
in the same gap was refused with `EACCES` (B174, fixed). The loop on 378667f failed
|
||||
seed 8: a file replaced by a rename in that gap stayed on Drive (B175, fixed), and
|
||||
seed 12: a write closed as its file's create landed was dropped (B176, fixed).
|
||||
The account run on 378667f: a folder listed as its create landed asked Drive by
|
||||
its stand-in and answered `EIO` (B177, fixed). A folder trashed while sync was
|
||||
paused kept a create below it as a trash: a paused drain worker had claimed it
|
||||
(B178, fixed). A file read as its create landed read empty (B179, fixed).
|
||||
The suite on 0f3c06f: a stop gave up on a drain worker that slept through its
|
||||
wake-up (B180, fixed). The account run on 903452a (2026-10-04, Wi-Fi: 262 passed,
|
||||
1 failed) failed only its cleanup, which found no control socket: after the mirror
|
||||
target's last restart the queue wait took the missing daemon for an empty queue
|
||||
(the script's side, fixed in 6539c73).
|
||||
5. **Release**: 2.10.0 is tagged; the 2.9.0 tag still waits for the user's go-ahead.
|
||||
|
||||
## After 3.0.0
|
||||
|
||||
- Remove the old online path in `filesystem.rs` and the `local_first` switch (the release after
|
||||
3.0.0).
|
||||
- **Phase 4**: one applier for remote changes.
|
||||
- **Phase 5**: mirror folders on the same planner and executor.
|
||||
- **Batched trashes and moves** (plan step 8). Not needed for the §7 target: the syscalls take
|
||||
5.2 s on LAN and 5.0 s on Wi-Fi. The drain's time goes to the creates' read-back (250 ms,
|
||||
then 1 s each), which batching does not shorten. A batch call answers for many ops at once,
|
||||
and splitting that answer back per op is the step the B15x to B17x races came from. First
|
||||
measure `rm -r` of 1,000 files on Wi-Fi. If it is slow, drop the queued trashes of a trashed
|
||||
folder's children before batching anything: trashing the folder takes them with it.
|
||||
- **Any time**: `Size` and block geometry as types; a deadline on the remote calls outside
|
||||
the drain (the drain's have one: `link::upload_deadline`, `Core::block_on_within`).
|
||||
- Open questions in §12 of the milestone.
|
||||
- `PLAN.md`: the acceptance script notices network drops and reports `interrupted` instead of
|
||||
TIMEOUT.
|
||||
+4
-5
@@ -5,7 +5,7 @@
|
||||
| Page | What it covers |
|
||||
|---|---|
|
||||
| [Installation](INSTALL.md) | Packages, building from source, first sign-in, updating and uninstalling |
|
||||
| [User guide](USER_GUIDE.md) | My files, available offline, synced folders, conflicts, Photos, sharing, Trash and versions, Computers, the search launcher, the tray |
|
||||
| [User guide](USER_GUIDE.md) | My files, available offline, synced folders, sync issues and conflicts, Photos, sharing, Trash and versions, Computers, the search launcher, the tray |
|
||||
| [Command-line reference](CLI.md) | Every `pdfs` command, grouped by task, and scripting with `--json` |
|
||||
| [Configuration](CONFIGURATION.md) | `config.json`, environment variables, file locations, the systemd service |
|
||||
| [Troubleshooting](TROUBLESHOOTING.md) | Diagnosing problems, common symptoms, starting over, reporting a bug |
|
||||
@@ -16,13 +16,12 @@
|
||||
|
||||
| Page | What it covers |
|
||||
|---|---|
|
||||
| [Development guide](DEVELOPMENT.md) | Building, running and debugging from source; releasing |
|
||||
| [Architecture](ARCHITECTURE.md) | Crates, read and write paths, sync engine, control protocol, threads, threat model |
|
||||
| [Testing](TESTING.md) | Unit tests and the FUSE acceptance suite |
|
||||
| [Development guide](DEVELOPMENT.md) | Building, running and debugging from source; common changes; releasing |
|
||||
| [Architecture](ARCHITECTURE.md) | Crates, the local tree, read and write paths, the queue, the sync engine, the control protocol, threads, threat model |
|
||||
| [Testing](TESTING.md) | Unit tests, simulation runs and the FUSE acceptance suite |
|
||||
| [Translating](TRANSLATING.md) | Adding and updating translations |
|
||||
| [Roadmap](ROADMAP.md) | Planned work and verification still owed |
|
||||
| [Bug ledger](BUGS.md) | Every tracked defect with its analysis and status |
|
||||
| [UI/UX plan](UI_UX_PLAN.md) | Design audit and notes behind the 2.x desktop app |
|
||||
|
||||
Also see [CONTRIBUTING.md](../CONTRIBUTING.md) and [SECURITY.md](../SECURITY.md) in the repository
|
||||
root.
|
||||
@@ -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.
|
||||
|
||||
+47
-39
@@ -1,8 +1,7 @@
|
||||
# Roadmap
|
||||
|
||||
Planned work and open verification, as of 3.0.0. Individual defects and their status live in
|
||||
[BUGS.md](BUGS.md), which is the authoritative ledger; this page groups what is left by theme.
|
||||
Nothing here is a promise of a date.
|
||||
Planned work and open verification after 3.0.0. Individual defects and their status are in
|
||||
[BUGS.md](BUGS.md); this page groups what is left by theme. Nothing here has a date.
|
||||
|
||||
- [Sync engine](#sync-engine)
|
||||
- [Data safety and recovery](#data-safety-and-recovery)
|
||||
@@ -14,38 +13,42 @@ Nothing here is a promise of a date.
|
||||
## Sync engine
|
||||
|
||||
3.0.0 records changes made through the mount locally and sends them from the queue
|
||||
([MILESTONE-3.0.0.md](MILESTONE-3.0.0.md)). What comes after it:
|
||||
([ARCHITECTURE.md §4](ARCHITECTURE.md#4-write-path)). What comes next:
|
||||
|
||||
| Item | State | Notes |
|
||||
|---|---|---|
|
||||
| Remove the online path | Not started | The old way of sending a change before the syscall returns, and the `local_first` switch that brings it back, go in the release after 3.0.0 |
|
||||
| One applier for remote changes | Not started | Phase 4 of the milestone: a remote tree, and remote changes applied through the same planner as local ones |
|
||||
| Synced folders on the queue | Not started | Phase 5: synced folders sent by the same planner and executor as the mount |
|
||||
| Remove the online path | Not started | The old way of sending a change inside the syscall, and the `local_first` switch that brings it back, go in the next release |
|
||||
| One applier for remote changes | Not started | Everything that comes from Drive (events, listings) changes the tree in one place, which is also the only place that tells the kernel. Replaces today's invalidation helpers and in-memory overlays |
|
||||
| Synced folders on the queue | Not started | Mirrored folders feed the local tree and use the same queue as the mount; `sync_entry` becomes the record of the last agreed state. Switching a folder's mode then moves no data except to evict or fetch content |
|
||||
| Batch trashes and moves | Deferred | See below |
|
||||
| Provisional values as types | Not started | A size or block geometry that is not known yet as its own type, so it cannot reach the kernel as if it were known |
|
||||
| Deadlines on every remote call | Partly done | The drain's calls and calls a syscall waits on have one; background calls outside the drain do not yet |
|
||||
|
||||
**Batching trashes and moves.** `trash_nodes` and `move_nodes_streaming` take up to 150 nodes per
|
||||
call, but the queue still sends one node per call. It was left out of 3.0.0 on purpose:
|
||||
**Batching trashes and moves.** `trash_nodes` and `move_nodes_streaming` take many nodes per call,
|
||||
but the queue sends one node per call. It was left out of 3.0.0 on purpose:
|
||||
|
||||
- The speed target of the milestone (§7) is met without it. Making 1,000 files in 100 folders
|
||||
takes 5.2 s on a LAN and 5.0 s on Wi-Fi.
|
||||
- What the queue spends its time on is the creates. Each one waits for Drive to list the new node
|
||||
before it counts as landed (250 ms, then 1 s), and batching does not help with that.
|
||||
- Sending many ops in one call means splitting one answer back into many results. Races in
|
||||
exactly that step were the largest class of bugs found for 3.0.0 (B15x to B17x).
|
||||
- The 3.0.0 speed target is met without it. Making 1,000 files in 100 folders through the mount
|
||||
takes 5.2 s on a LAN profile and 5.0 s on a Wi-Fi profile in the simulation
|
||||
(`PDFS_SIM_MEASURE`, see [TESTING.md](TESTING.md#simulation-runs)).
|
||||
- The drain spends its time on creates. Each waits for Drive to list the new node before it
|
||||
counts as landed, and batching does not shorten that.
|
||||
- One call answering for many ops has to be split back into results per op. Races in exactly that
|
||||
step were the largest class of bugs found for 3.0.0 (B15x to B17x in [BUGS.md](BUGS.md)).
|
||||
|
||||
Before building it, measure `rm -r` of a folder with 1,000 files on Wi-Fi. If that is slow, the
|
||||
cheaper fix is to drop the queued trashes of the files in a folder when the folder itself is
|
||||
trashed, since trashing the folder takes its contents with it.
|
||||
cheaper fix is to drop the queued trashes of a trashed folder's contents: trashing the folder takes
|
||||
them along.
|
||||
|
||||
## Data safety and recovery
|
||||
|
||||
| Item | State | Notes |
|
||||
|---|---|---|
|
||||
| Protect queued changes at shutdown | Not started | A systemd inhibitor lock while `staging/` holds unsent data, a warning when signing out, and a blocking `pdfs sync flush`. Today the queue is visible through `pdfs sync queue`, the tray and **Sync → Overview**, but nothing stops a shutdown (B66) |
|
||||
| Restore pins from the machine profile | Not started | `profile.json` records pins; `sync restore` does not apply them yet |
|
||||
| Profile backup health | Not started | A failed profile upload is a `WARN` in the log and nothing in the UI (B68) |
|
||||
| Rotated credentials survive a keyring failure | Open | Surface and retry keyring write failures so a rotated single-use refresh token is not lost (B61) |
|
||||
| Encrypt local state at rest | Not started | SQLCipher for `cache.db` and an AEAD for the content cache, keyed from the keyring. `sdk_cache.db` is already encrypted. See [ARCHITECTURE.md §8](ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext) |
|
||||
| Protect queued changes at shutdown | Not started | A systemd inhibitor lock while `staging/` holds unsent data, a warning when signing out, and a `pdfs sync flush` that waits for the queue. Today the queue is visible in `pdfs sync queue`, the tray and **Sync → Overview**, but nothing stops a shutdown (B66) |
|
||||
| Restore pins from the machine profile | Not started | `profile.json` records pins; `pdfs sync restore` does not apply them yet |
|
||||
| Profile backup health | Not started | A failed profile upload is a `WARN` in the log and nothing in the app (B68) |
|
||||
| Rotated credentials survive a keyring failure | Open | Report and retry keyring write failures, so a rotated single-use refresh token is not lost (B61) |
|
||||
| Restore never picks a new device silently | Open | Ask when the machine's device is ambiguous after a loss of local state (B67) |
|
||||
| Encrypt local state at rest | Not started | SQLCipher for `cache.db` and an AEAD for the content cache, keyed from the keyring. `sdk_cache.db` is already encrypted. See [ARCHITECTURE.md §10](ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext) |
|
||||
|
||||
## Features
|
||||
|
||||
@@ -53,9 +56,10 @@ trashed, since trashing the folder takes its contents with it.
|
||||
|---|---|---|
|
||||
| File-manager integration | Not started | Context-menu actions (available offline, share link, versions) and sync badges for Nautilus, Dolphin and Thunar |
|
||||
| Terminal UI | Not started | `pdfs tui` with transfers, sync state and logs |
|
||||
| Multiple accounts | Not started | Separate config, state and mountpoint per profile, one daemon each |
|
||||
| Multiple accounts | Not started | Separate config, state and mountpoint per account, one daemon each |
|
||||
| Mount options | Not started | Read-only mount, `allow_other` |
|
||||
| Symbolic links | Not started | Proton Drive has no link type; links would need a placeholder format. Until then, creating one fails with `EOPNOTSUPP` |
|
||||
| Symbolic links | Not started | Proton Drive has no link type; links would need a placeholder format. Until then, `symlink` fails with `EPERM` |
|
||||
| `fsync` that waits for Drive | Idea | `fsync` means "durable locally". A mount option or an xattr could offer "on Drive" without slowing the default |
|
||||
| Add someone else's photo to your album | Blocked | Needs `copyPhoto` in the SDK |
|
||||
| Photo name search | Not started | Photos search by date and filters only |
|
||||
| Automatic photo upload from a folder | Not started | |
|
||||
@@ -66,34 +70,38 @@ trashed, since trashing the folder takes its contents with it.
|
||||
|
||||
| Item | Notes |
|
||||
|---|---|
|
||||
| Saturating stream-ring accounting | `self.bytes -= …` in `crates/pdfs-fuse/src/reads.rs` should saturate so an accounting drift cannot wrap in release builds |
|
||||
| Saturating stream-ring accounting | `self.bytes -= …` in `crates/pdfs-fuse/src/reads.rs` should saturate, so an accounting drift cannot wrap in release builds |
|
||||
| Block geometry learned one read late | The first read of a file plans on 4 MiB blocks; accepted as a bounded cost (B87) |
|
||||
| Path-based commands under secondary mounts | `pin`, `rename`, `move`, `rm` and sharing cannot name a path inside an on-demand synced folder yet (B88) |
|
||||
| Unicode normalization | NFC and NFD names are distinct, deliberately (B36) |
|
||||
| Pipelined node enumeration | Faster cold listings of large folders |
|
||||
| Path-based commands under online-only folders | `pin`, `unpin`, opening a file, uploads, sharing and versions cannot name a path inside an online-only synced folder yet. `rm`, `rename`, `mkdir` and `move` can (B88) |
|
||||
| Unicode normalization | NFC and NFD names are distinct, on purpose (B36) |
|
||||
| Pipelined node enumeration | Faster first listings of large folders |
|
||||
|
||||
## Verification still owed
|
||||
|
||||
Fixes marked *unverified* in [BUGS.md](BUGS.md) have unit or offline coverage but have not been
|
||||
driven against a live account. The larger outstanding runs:
|
||||
Fixes marked *unverified* in [BUGS.md](BUGS.md) have unit or simulation coverage but have not
|
||||
been driven against a live account. The larger runs still owed:
|
||||
|
||||
- **A clean account run on 3.0.0.** The last account run before the release (2026-10-04, on Wi-Fi)
|
||||
failed only its cleanup, on a script bug fixed since. No full run with `--journal-check` has
|
||||
passed on the released code yet.
|
||||
- **Disaster-recovery drill.** Register a device, sync a folder, delete all local state, restore
|
||||
with `pdfs sync restore` and compare the tree byte for byte. See [RECOVERY.md](RECOVERY.md).
|
||||
- **Second-account sharing.** Shared trees on a foreign volume, accepting invitations, changing
|
||||
with `pdfs sync restore`, and compare the tree byte for byte. See [RECOVERY.md](RECOVERY.md).
|
||||
- **Second-account sharing.** Shared folders on a foreign volume, accepting invitations, changing
|
||||
roles, the viewer cases of `regression B34`, and an editor writing into someone else's share.
|
||||
- **Fault injection.** Network loss mid-upload, a full disk during staging, and crash points
|
||||
across the write publication path (after each fsync, rename and queue commit).
|
||||
- **Fault injection on a real account.** Network loss mid-upload, a full disk during staging, and
|
||||
crashes at each step of publishing a write. The simulation covers link loss and restarts against
|
||||
the fake Drive, not a kill at an arbitrary point.
|
||||
- **Acceptance runs across a network drop.** A case that spans an outage times out while the queue
|
||||
replays, instead of being reported as interrupted.
|
||||
- **CAPTCHA sign-in** against a real verification request (B8).
|
||||
- **Managed live mode matrix** before each release:
|
||||
`scripts/fuse-acceptance.sh --managed-live <empty-a> <empty-b>`. See [TESTING.md](TESTING.md).
|
||||
- **The overnight hang** (B91) is not diagnosed yet. The supervisor and `pdfs diagnostics` exist to
|
||||
catch the next occurrence.
|
||||
- **The overnight hang** (B91) is not diagnosed. The supervisor and `pdfs diagnostics` exist to
|
||||
catch the next one.
|
||||
|
||||
## Release assurance
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Tag, workspace and PKGBUILD version agreement | Enforced by the release workflow |
|
||||
| Tag, workspace and PKGBUILD version agree | Enforced by the release workflow |
|
||||
| Clean-install test of the `.deb` and `.rpm` in a VM | Pending (B62) |
|
||||
| Protocol version identity between front ends and daemon | Open (B63) |
|
||||
| SBOM, provenance and checksums for release artifacts | Open (B65). The RPM is GPG-signed |
|
||||
+207
-451
@@ -1,509 +1,265 @@
|
||||
# Testing
|
||||
|
||||
The normal Rust suite exercises the metadata database, sync planner, offline
|
||||
queue, write staging, cache, and FUSE handler state without requiring a Proton
|
||||
account:
|
||||
Three layers check the client, from fast and offline to slow and against a real account:
|
||||
|
||||
```console
|
||||
| Layer | Needs | Runs in CI |
|
||||
|---|---|---|
|
||||
| [Unit tests](#unit-tests) | Nothing | Every push and pull request |
|
||||
| [Simulation runs](#simulation-runs) | `/dev/fuse` | Every push to `main` |
|
||||
| [Acceptance suite](#acceptance-suite) | Nothing for the reference run; a signed-in account for the rest | The reference run only |
|
||||
|
||||
## Unit tests
|
||||
|
||||
```bash
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
## Filesystem API and live FUSE acceptance suite
|
||||
Tests sit in `#[cfg(test)]` modules next to the code. Database tests are in
|
||||
`crates/pdfs-core/src/db/tests.rs`; a schema change adds one that migrates from the previous
|
||||
version.
|
||||
|
||||
The account-free suite exercises the filesystem syscall contract against a
|
||||
temporary local directory. This validates the runner and covers creation/open
|
||||
flags, positioned and vectored I/O, truncation and sparse files, `mmap`,
|
||||
`sendfile`, `copy_file_range`, durability barriers, namespace/error semantics,
|
||||
`renameat2` flags, open-file lifetime, names and enumeration, readdir stability
|
||||
under concurrent mutation, metadata updates, extended attributes, allocation and
|
||||
punched holes, refusal semantics for unimplemented operations, concurrent I/O,
|
||||
and real application workloads. It never reads application state or contacts
|
||||
Proton:
|
||||
To check the migrations against a real database, point `PDFS_MIGRATE_DB` at it and run the
|
||||
ignored test. It migrates a copy taken with `VACUUM INTO` and never writes the database named:
|
||||
|
||||
```console
|
||||
scripts/fuse-acceptance.sh --offline-only
|
||||
```bash
|
||||
PDFS_MIGRATE_DB=~/.local/state/proton-drive-linux/cache.db \
|
||||
cargo test -p pdfs-core a_copy_of_a_real_database -- --ignored
|
||||
```
|
||||
|
||||
Kernel/FUSE behavior and remote convergence need a real mount. With no
|
||||
arguments, the script tests the account the running service is logged in to:
|
||||
## Simulation runs
|
||||
|
||||
```console
|
||||
scripts/fuse-acceptance.sh # every case, every mount kind
|
||||
scripts/fuse-acceptance.sh --quick # one on-demand/mirror pair instead of four
|
||||
The simulation runs (`crates/pdfs-fuse/src/sim/`) start real daemons with real FUSE mounts against
|
||||
an in-memory Drive, `FakeDrive`. The daemon runs as in production (drain workers, sync engine,
|
||||
event feed, online probe, control socket); only its Drive is fake. `FakeDrive` has the faults the
|
||||
real one has: listings that lag a create, renames refused for a stale name hash, no atomic
|
||||
replace, delayed and reordered events, requests that stall or lose their answer, per-request
|
||||
latency, and blocks of uneven size.
|
||||
|
||||
A run draws a sequence of steps from a seed: syscalls through a client's mount, the link going
|
||||
down and up, restarts, settles (every queue must drain within a budget), and files deleted while
|
||||
the drain holds a write to them. Every syscall is checked against an in-memory model of the
|
||||
filesystem as it happens. At the end every link comes up and the run checks:
|
||||
|
||||
1. **No loss and POSIX.** Drive's copy of each client's folder is what its model says.
|
||||
2. **No false conflicts.** No conflict copy anywhere; each file has one writer.
|
||||
3. **Convergence.** Each mount shows Drive's tree.
|
||||
4. **Liveness.** No syscall took longer than its budget, and every queue drained once the links
|
||||
were up. A call that never returns is caught by a watchdog that writes every thread's stack to
|
||||
`stacks.txt` in the run's state directory.
|
||||
|
||||
| Test | Seeds |
|
||||
|---|---|
|
||||
| `one_client_on_a_good_link` | 12 |
|
||||
| `one_client_deleting_files_as_they_upload` | 3 |
|
||||
| `one_client_with_stale_hashes_and_reordered_echoes` | 6 |
|
||||
| `one_client_on_a_flaky_link` | 4 |
|
||||
| `one_client_on_a_slow_link` | 4 |
|
||||
| `three_clients_one_writer_each` | 3 |
|
||||
|
||||
`sim/daemon.rs` holds scenario tests for single bugs, run the same way.
|
||||
|
||||
The tests mount FUSE, so they are ignored by default:
|
||||
|
||||
```bash
|
||||
cargo test -p pdfs-fuse --lib sim:: -- --ignored --test-threads=1 # what CI runs
|
||||
PDFS_SIM_SEEDS=50 cargo test -p pdfs-fuse --lib sim::run -- --ignored --test-threads=1
|
||||
PDFS_SIM_SEED=7 RUST_LOG=pdfs_fuse=debug cargo test -p pdfs-fuse --lib \
|
||||
sim::run::tests::one_client_on_a_flaky_link -- --ignored --exact --nocapture
|
||||
```
|
||||
|
||||
An account run does this, in order:
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `PDFS_SIM_SEEDS` | How many seeds each test runs, from 1 |
|
||||
| `PDFS_SIM_SEED` | Replay one seed. A failing run prints the seed, the last steps, and where it kept its state |
|
||||
| `PDFS_SIM_MEASURE` | Run `a_thousand_files_drain_as_fast_on_wifi_as_on_lan`, which times 1,000 files in 100 folders on a LAN and a Wi-Fi profile and prints the times |
|
||||
|
||||
1. Runs the account-free contract as the reference.
|
||||
2. Runs the contract in a fresh `pdfs-acceptance-<id>` folder in My files and
|
||||
diffs it against the reference. After the last case it reads back a file
|
||||
the run wrote and records its digest. A read that fails there, as one did
|
||||
with the link down (B119), is a failed result named "a file of this run
|
||||
reads back at the end", and the cleanup still runs.
|
||||
3. Registers two new sync folders under `~/.cache/pdfs-acceptance/run-<id>/`
|
||||
and runs the contract and the move cases in every on-demand/mirror pairing.
|
||||
4. Removes everything it created, locally and remotely, and reports a
|
||||
`cleanup` result. The run fails if anything is left behind.
|
||||
A failure is a seed that can be replayed, but the daemon uses real threads, so a race may need
|
||||
several replays. In CI, the `simulation` job uploads the stacks of a hung run as an artifact.
|
||||
|
||||
Cleanup identifies remote folders by uid, never by name alone, so nothing of
|
||||
yours is touched. It removes each sync folder with `sync rm --delete-remote`,
|
||||
trashes the My files folder, and then deletes both permanently from the trash.
|
||||
It only counts a folder as gone once a fresh trash listing no longer shows it.
|
||||
It then reads `pdfs sync queue` and counts any queued op that still names one
|
||||
of its folders as left behind too, since such an op keeps its staged bytes and
|
||||
retries forever (B102).
|
||||
## Acceptance suite
|
||||
|
||||
Ops that were queued before a target starts are not the run's. The run prints
|
||||
one `NOTE` line naming them, and the queue waits ignore them. They are told
|
||||
apart by id and queue time together, since SQLite can give a new op the id of
|
||||
one that has drained.
|
||||
`scripts/fuse-acceptance.sh` checks the filesystem contract a program sees: creation and open
|
||||
flags, positioned and vectored I/O, truncation and sparse files, `mmap`, `sendfile`,
|
||||
`copy_file_range`, durability barriers, renames and their `renameat2` flags, open-file lifetime,
|
||||
names, `readdir` under concurrent change, metadata, extended attributes, holes, refusals of
|
||||
unsupported operations, concurrent I/O, and real application workloads.
|
||||
|
||||
A write is queued when the kernel releases the file, and `close(2)` returns
|
||||
without waiting for that. A case that reads `pdfs sync queue` right after
|
||||
writing therefore waits for the ops it expects (`Daemon.queued`) instead of
|
||||
reading once. "regression B102" saw one revision of two before it did.
|
||||
> [!CAUTION]
|
||||
> Every mode except `--offline-only` creates and deletes real files in Proton Drive. Use a test
|
||||
> account with nothing you cannot lose.
|
||||
|
||||
Cleanup also runs on a failure, Ctrl-C, `SIGTERM` or `SIGHUP`. While it runs,
|
||||
two more interrupts are ignored; a third abandons it.
|
||||
### Modes
|
||||
|
||||
Each run records what it is about to create in
|
||||
`~/.cache/pdfs-acceptance/run-<id>/manifest.json` before it creates it, and
|
||||
holds a lock on that directory. If a run dies without cleaning up (`kill -9`, a
|
||||
hard timeout, a power cut), the next run finds the unlocked manifest and
|
||||
finishes that cleanup first. It then removes stray `pdfs-acceptance-<32 hex>`
|
||||
folders that no live run claims: in My files (older than
|
||||
`PDFS_ACCEPTANCE_REAP_AGE`, default one hour), under the acceptance home, and
|
||||
in the trash. A run does not start while that reap leaves anything behind.
|
||||
|
||||
The run creates and destroys real remote folders, so a dedicated test account
|
||||
without irreplaceable data is still the safest target.
|
||||
|
||||
`--live PATH...` tests mounts you set up yourself and leaves the sync folder
|
||||
configuration alone. It runs destructive POSIX operations only below a fresh
|
||||
`pdfs-acceptance-*` directory, refuses paths that are not FUSE mounts, and
|
||||
removes its directory on success, failure, or interruption:
|
||||
|
||||
```console
|
||||
scripts/fuse-acceptance.sh --live /mnt/on-demand-testmount
|
||||
```bash
|
||||
scripts/fuse-acceptance.sh --offline-only # the reference run; no account
|
||||
scripts/fuse-acceptance.sh # the signed-in account, every mode pairing
|
||||
scripts/fuse-acceptance.sh --quick # one on-demand/mirror pairing instead of four
|
||||
scripts/fuse-acceptance.sh --live /mnt/testmount # mounts you set up yourself
|
||||
scripts/fuse-acceptance.sh --managed-live /mnt/a /mnt/b
|
||||
```
|
||||
|
||||
**The reference run** always runs first, against a temporary local directory. It records the
|
||||
facts each case establishes, and every mount is then **compared** against those facts. A case
|
||||
fails when the mount differs from an ordinary filesystem, not only when it raises. A fact that may
|
||||
legitimately differ (inode numbers, block counts, which optional syscalls exist) is **noted**
|
||||
instead, and listed under the target's `accepted` line. The choice is made in the test body, so
|
||||
the accepted differences are visible where they are accepted. Prefer `record`; use `note` only
|
||||
for something a network filesystem cannot do.
|
||||
|
||||
**An account run** (no mode):
|
||||
|
||||
1. Runs the reference.
|
||||
2. Runs the contract in a new `pdfs-acceptance-<id>` folder in My files, and at the end reads back
|
||||
a file the run wrote.
|
||||
3. Registers two sync folders under `~/.cache/pdfs-acceptance/run-<id>/` and runs the contract and
|
||||
the move cases in every pairing of on-demand and mirror. Before a folder switches to on-demand,
|
||||
a new file is written into it; once mounted, the file must be listed with its bytes. In every
|
||||
pairing each folder must still hold the file written into it at setup.
|
||||
4. Removes everything it made, locally and on Drive, and reports `cleanup`. A run that leaves
|
||||
anything behind fails.
|
||||
|
||||
Cleanup finds remote folders by uid, never by name, so nothing of yours is touched. It removes
|
||||
each sync folder with `sync rm --delete-remote`, trashes the My files folder, deletes both from
|
||||
the trash for good, and checks that a fresh trash listing no longer shows them. Queued ops that
|
||||
still name one of its folders count as left behind. Cleanup also runs on a failure, Ctrl-C,
|
||||
`SIGTERM` or `SIGHUP`; while it runs, two more interrupts are ignored and a third abandons it.
|
||||
|
||||
Each run writes what it is about to create to `~/.cache/pdfs-acceptance/run-<id>/manifest.json`
|
||||
first and holds a lock on that directory. A later run finishes the cleanup of a run that died, and
|
||||
removes stray `pdfs-acceptance-<32 hex>` folders no live run claims: in My files (older than
|
||||
`PDFS_ACCEPTANCE_REAP_AGE`, default one hour), under the acceptance home, and in the trash. It
|
||||
does not start while anything is left.
|
||||
|
||||
Ops that were queued before the run started are not the run's: it prints one `NOTE` line naming
|
||||
them, and its queue waits ignore them.
|
||||
|
||||
**`--live PATH...`** runs the contract on mounts you set up, and leaves the sync configuration
|
||||
alone. It works only below a new `pdfs-acceptance-*` directory, refuses paths that are not FUSE
|
||||
mounts, and removes its directory afterwards. The first path must be a FUSE mount; later paths may
|
||||
be another mount or a mirrored folder. If all paths show the **same remote folder**, set
|
||||
`PDFS_ACCEPTANCE_CONVERGENCE=1` to also check that bytes written through the first appear through
|
||||
the others.
|
||||
|
||||
**`--managed-live A B`** takes two existing, empty, unmounted directories, registers them as sync
|
||||
folders, and runs every pairing as an account run does, then deletes the remote folders. It
|
||||
refuses non-empty paths, mounts, and paths already in `pdfs sync list`.
|
||||
|
||||
### Reading the output
|
||||
|
||||
A run opens with what it is about to test: the mode, the `pdfs` binary and its
|
||||
version, Python and the kernel, the per-case limit, the options and the start
|
||||
time. Each target then names its root, the storage behind it, the daemon it
|
||||
talks to and how many cases it runs, followed by one line per case:
|
||||
A run opens with the mode, the `pdfs` binary and version, Python, the kernel, the per-case limit
|
||||
and the options. Each target then names its root, the storage behind it, the daemon and the
|
||||
number of cases, followed by one line per case:
|
||||
|
||||
```
|
||||
==> live FUSE /mnt/testmount
|
||||
-> root /mnt/testmount/pdfs-acceptance-…
|
||||
-> storage FUSE mount /mnt/testmount (fuse.protondrive)
|
||||
-> daemon pdfs 2.8.3, user@proton.me, online, 0 queued ops
|
||||
-> daemon pdfs 3.0.0, user@proton.me, online, 0 queued ops
|
||||
-> cases 45 of 45
|
||||
[ 1/45] creation and open flags .................................. ok 0.41s
|
||||
…
|
||||
[21/45] throughput floors ........................................ ok 48.20s
|
||||
write 31 MiB/s, read 54 MiB/s, 4 KiB writes 12.0 MiB/s, metadata 9 ops/s
|
||||
…
|
||||
[43/45] regression B115: pdfs ls reports a file's real size ...... ok 6.12s
|
||||
-> reference matches the ordinary filesystem in 412 compared observations
|
||||
-> result 44 passed, 1 skipped in 11m02s
|
||||
```
|
||||
|
||||
A case ends as `ok`, `FAIL`, `TIMEOUT`, `skip` when it does not apply to the
|
||||
target (no daemon, no share at that role), or `known` when an `open B<n>` case
|
||||
reproduced a bug that is still open (see below). The dimmed lines under a case
|
||||
are its details: the reason for a skip, measured rates, a hash. A failed
|
||||
`regression B<n>` case also points to its entry in `docs/BUGS.md`. When a case
|
||||
prints something itself, such as a service restart, the case line is printed
|
||||
again once the case ends.
|
||||
A case ends as `ok`, `FAIL`, `TIMEOUT`, `skip` (it does not apply to the target), or `known` (an
|
||||
`open B<n>` case reproduced a bug that is still open). Dimmed lines under a case hold its details.
|
||||
A failed `regression B<n>` case points to its entry in [BUGS.md](BUGS.md).
|
||||
|
||||
The run ends with a table of every target's counts and time, the report paths,
|
||||
the slowest cases, the cases over `--budget`, the known issues that still
|
||||
reproduce, any errors the daemon logged, and each failure with its target. The
|
||||
last line is `PASS:` or `FAIL:` and the totals. Colour is used only on a
|
||||
terminal and never with `NO_COLOR` set.
|
||||
The run ends with a table of every target, the report paths, the slowest cases, cases over
|
||||
`--budget`, known issues that still reproduce, errors the daemon logged, and each failure. The
|
||||
last line is `PASS:` or `FAIL:` with the totals. Colour is used only on a terminal and never with
|
||||
`NO_COLOR` set.
|
||||
|
||||
### Edge cases and performance
|
||||
### What the cases cover
|
||||
|
||||
Besides the syscall contract, the suite covers:
|
||||
Besides the syscall contract:
|
||||
|
||||
- **Block boundaries.** Files of 0 bytes up to two 4 MiB blocks plus 3 bytes,
|
||||
including every size next to a boundary, then overwrites that grow, shrink
|
||||
and reshape them.
|
||||
- **Unusual names.** A 255-byte name, NFC and NFD forms of the same text,
|
||||
newline, tab, emoji, characters Windows forbids, leading and trailing
|
||||
spaces, and a case-only rename.
|
||||
- **A deep tree and a wide directory.** 24 levels of nesting renamed at the
|
||||
top; 128 entries created concurrently, concurrent unlinks, then `rm -rf`.
|
||||
Online, each create is a round trip to the server, and the kernel runs
|
||||
creates in one directory one at a time. So the wide case gets twice the
|
||||
per-case timeout and notes the time per create as `wide.create_ms`.
|
||||
- **Rename patterns.** Swaps, chains, log rotation, replacing a file under an
|
||||
open reader, the `EISDIR`/`ENOTDIR`/`ENOENT` refusals, and a folder moved out
|
||||
and back.
|
||||
- **Handle coherency.** Unsynced writes seen by a second handle, two `O_APPEND`
|
||||
writers, 30 rewrite generations, and a truncate under a reader.
|
||||
- **Throughput.** 64 MiB written and read sequentially, 1 MiB in 4 KiB writes,
|
||||
and metadata operations on 100 files. The rates are printed. The case fails
|
||||
below `PDFS_ACCEPTANCE_MIN_MIBPS` (default 10) or `PDFS_ACCEPTANCE_MIN_OPS`
|
||||
(default 20 operations per second, or 3 on a FUSE mount, where each create
|
||||
and unlink waits for Drive before it returns).
|
||||
- **Block boundaries.** Files from 0 bytes to two 4 MiB blocks plus 3 bytes, every size next to a
|
||||
boundary, and overwrites that grow, shrink and reshape them.
|
||||
- **Unusual names.** 255 bytes, NFC and NFD forms of the same text, newline, tab, emoji,
|
||||
characters Windows forbids, leading and trailing spaces, a case-only rename.
|
||||
- **A deep tree and a wide folder.** 24 levels renamed at the top; 128 entries created
|
||||
concurrently, unlinked concurrently, then `rm -rf`.
|
||||
- **Rename patterns.** Swaps, chains, log rotation, replacing a file under an open reader, the
|
||||
`EISDIR`, `ENOTDIR` and `ENOENT` refusals.
|
||||
- **Handle coherency.** Unsaved writes seen by a second handle, two `O_APPEND` writers, 30 rewrite
|
||||
generations, a truncate under a reader.
|
||||
- **Throughput.** 64 MiB written and read, 1 MiB in 4 KiB writes, metadata on 100 files. The case
|
||||
fails below `PDFS_ACCEPTANCE_MIN_MIBPS` (default 10) or `PDFS_ACCEPTANCE_MIN_OPS` (default 20
|
||||
operations per second, 3 on a FUSE mount). `0` turns a floor off.
|
||||
- **Operations the filesystem does not implement.** `git`, `rsync`, `cp -a`, `tar` and installers
|
||||
probe for symlinks, hard links, FIFOs, device nodes, locks, `O_DIRECT` and `SEEK_HOLE`. Each must
|
||||
work or be refused cleanly: a known errno, never `EIO`, never a hang.
|
||||
- **Moves between locations** (`managed` in `--list`). Trees moved between two sync folders in
|
||||
both directions, several sources in one `pdfs move`, and a tree through My files and out again.
|
||||
The refusals must move nothing and lose nothing: a name already taken, a folder into itself, a
|
||||
mirrored source holding something Drive lacks, a file still open for writing.
|
||||
- **The CLI.** `pdfs` into a closed pipe exits 0 with nothing on stderr; `pdfs mkdir`, `rename`
|
||||
and `rm` by local path, and their refusals.
|
||||
|
||||
### The mount is diffed against an ordinary filesystem
|
||||
### Regressions and open bugs
|
||||
|
||||
The local reference run is not only a self-test of the runner. Every case
|
||||
records the facts it established, and a live run is compared against those
|
||||
recordings, so a case fails when the mount *differs* from an ordinary
|
||||
filesystem — not only when it raises. The target's `reference` line names the
|
||||
exact key:
|
||||
A case named `regression B<n>` reproduces an entry of [BUGS.md](BUGS.md) so it stays fixed. Most
|
||||
need a live mount and a reachable daemon, and are skipped without one. `--list` prints every case
|
||||
and the targets it runs on.
|
||||
|
||||
```
|
||||
-> reference 1 observation of 412 differs from the ordinary filesystem:
|
||||
! creation and open flags.truncated.size: live FUSE /mnt/testmount=6 reference=0
|
||||
```
|
||||
A regression case checks the outcome **on Drive**, so it waits for the queue first. Reading back
|
||||
through the mount proves nothing: the local caches return the bytes just written whether or not
|
||||
they reached Drive. A write is queued when the kernel releases the file, after `close(2)` has
|
||||
returned, so a case that reads `pdfs sync queue` right after writing waits for the ops it expects.
|
||||
|
||||
Facts are recorded in one of two ways, chosen in the test body:
|
||||
A case named `open B<n>` checks a bug that is still open. While it reproduces, the case ends as
|
||||
`known`: listed at the end, but not a failure. When it passes, the bug is fixed: rename the case to
|
||||
`regression B<n>` and make it fail with `check`. No case is open at the moment.
|
||||
|
||||
- **compared** — a claim about filesystem semantics. The mount must agree.
|
||||
- **noted** — context that legitimately differs: inode numbers, block counts,
|
||||
reported mode bits, and which optional syscalls exist at all. Listed under
|
||||
the target's `accepted` line, never a failure.
|
||||
Some cases need what a test account cannot provide alone and skip without it: B34 needs a folder
|
||||
shared read-only by a **second account**, B34b one shared with editor rights, and B79 an on-demand
|
||||
synced folder.
|
||||
|
||||
Because the choice is made at each call site, the set of accepted divergences
|
||||
is visible in the test that accepts it. There is no separate allowlist to drift
|
||||
out of date. When adding a case, prefer `record`; reach for `note` only when an
|
||||
ordinary filesystem can do something a network filesystem genuinely cannot.
|
||||
### Timeouts, reports and hung mounts
|
||||
|
||||
### Operations the filesystem does not implement
|
||||
A hung FUSE call cannot be interrupted from Python. Each case runs under a soft alarm (`--timeout`,
|
||||
default 180 s, or `PDFS_ACCEPTANCE_TIMEOUT`) that reports the case and lets cleanup run, backed by
|
||||
a hard stop that dumps every thread's stack and exits. After a timeout the run checks whether the
|
||||
mount still answers a `stat` and a listing within 15 s. If it does, the target goes on; if not, its
|
||||
remaining cases are skipped. Cases that make many round trips by design get twice the limit.
|
||||
|
||||
`git`, `rsync`, `cp -a`, `tar`, `df` and most installers probe for symlinks,
|
||||
hard links, FIFOs, device nodes, `statfs`, file locks, `copy_file_range`,
|
||||
`O_DIRECT` and `SEEK_HOLE` whether or not a filesystem offers them. The suite
|
||||
asserts that each either works or is *refused cleanly* — a recognised errno,
|
||||
never `EIO` and never a hang. A refusal that is merely unimplemented is fine;
|
||||
one that is dirty breaks software that never asked for the feature.
|
||||
|
||||
### Regressions from the bug ledger
|
||||
|
||||
Cases named `regression B<n>` reproduce a bug from `docs/BUGS.md` so it stays
|
||||
fixed. B69 (identical rewrite must not fork a `(sync-conflict …)` copy), B70 (an
|
||||
in-flight `.crdownload` must not be sealed as a revision) and B74 (a file
|
||||
renamed after close must keep its content) need a real mount and a reachable
|
||||
daemon; they are skipped, not failed, without one.
|
||||
|
||||
**B100** (a copied tree reads back at its exact sizes) copies 48 files of
|
||||
assorted sizes into the mount at once, as `cp -r` does. On a live mount it
|
||||
waits for the queue, checks every size and byte, runs `pdfs refresh` on each
|
||||
folder to drop the cached listing, and checks again. The refresh is what
|
||||
brought back the provisional sizes the bug reported.
|
||||
|
||||
**B101** (a write during its create's upload reaches Drive) holds new files open for
|
||||
different times before writing, so their empty creates land before, during and after the
|
||||
bytes arrive, then checks every file after the queue drains.
|
||||
|
||||
**B113** (moving a file before its upload makes no conflict) pauses sync with
|
||||
`pdfs sync pause --for 10m` and writes three files. While their uploads are held,
|
||||
it renames one, moves one into a folder, and renames and moves the third. It then
|
||||
resumes sync, waits for the queue, and checks the bytes and names and that no
|
||||
conflict copy appeared. It skips when sync was already paused. If the run dies
|
||||
before the resume, the pause ends by itself after ten minutes.
|
||||
|
||||
**B88** (`pdfs mkdir`, `rename` and `rm` by local path) creates, renames and
|
||||
removes a folder and a file by their paths in the mount. Then it checks the
|
||||
refusals: `pdfs rename` of the mount's root to its own name, and `pdfs rm` of a
|
||||
file outside every mount. `pdfs rm` of the root itself is tried only on a sync
|
||||
folder the run created. In a mirrored sync folder all three commands must be
|
||||
refused instead, and nothing may change.
|
||||
|
||||
**pdfs output into a closed pipe** runs `pdfs ls`, `pdfs --json ls` and
|
||||
`pdfs status` with stdout on a pipe whose reader is already gone. Each must exit
|
||||
0 with nothing on stderr, as `pdfs ls | head -1` needs.
|
||||
|
||||
**B102** (removing a folder drops the uploads queued inside it) uploads a file
|
||||
into each of two nested folders, pauses sync and rewrites both. While the
|
||||
revisions are held, it removes one folder with `pdfs rm` and the other through
|
||||
the mount. Neither revision may stay queued, and neither folder may come back
|
||||
after the resume. It skips when sync was already paused.
|
||||
|
||||
**B94** (a deleted `.part` file leaves nothing queued) writes a `.part` file,
|
||||
checks that its create is parked, and unlinks it. The parked op must leave the
|
||||
queue within 30 seconds. The sweep that lets an hour-old park through is not
|
||||
driven.
|
||||
|
||||
**B95** (a `.tmp` file uploads at once) writes `index.tmp` and `preview.temp`
|
||||
and waits for the queue, which fails at once on a parked op. `pdfs ls` must
|
||||
list both, and their bytes must read back.
|
||||
|
||||
**B111** (renaming a file straight back works) renames a file there and back
|
||||
eight times through the mount. On a mount it also does so three times with
|
||||
`pdfs rename`, after the queue is empty, so the renames race nothing but Drive.
|
||||
It runs against the reference too.
|
||||
|
||||
**B121** (a file renamed and moved at once lands) renames a file, moves it into
|
||||
a subfolder and renames it back across folders through the mount, with no pause
|
||||
between the steps. On a mount it then runs `pdfs rename` and `pdfs move` back to
|
||||
back. The file must end up where it was sent, with its bytes, and the case
|
||||
reports how long each half took. It runs against the reference too.
|
||||
|
||||
**B98** (an upload superseded by a write ends) writes 64 MiB and waits for its
|
||||
upload to show in `pdfs transfers`, then appends to the file. The queue must
|
||||
drain, the old upload must leave the transfer list, and the file must hold
|
||||
every byte with no conflict copy. It skips when the upload is never seen on
|
||||
the wire, and it gets twice the per-case timeout.
|
||||
|
||||
**B80** (`pdfs search` finds a new file after a rewrite) writes a file whose
|
||||
name carries a unique token, waits for the queue, rewrites it and waits again.
|
||||
`pdfs search` must find it within 30 seconds.
|
||||
|
||||
**B48** (an emptied folder can be removed at once) writes twelve files into a
|
||||
folder, lists it, and moves one more in from elsewhere. It unlinks every
|
||||
child, runs `pdfs refresh` on a mount, and checks that nothing is listed and
|
||||
that `rmdir` works. It runs against the reference too.
|
||||
|
||||
**B114** (a name `pdfs rm` or `rename` took away stops resolving) looks up two
|
||||
files, removes one with `pdfs rm` and renames the other with `pdfs rename`. At
|
||||
once, neither old name may be listed or resolve.
|
||||
|
||||
**B115** (`pdfs ls` reports a file's real size) writes files of 6 bytes, 4097
|
||||
bytes and one block plus 7, and compares the sizes `pdfs ls` lists with the
|
||||
written ones, after the upload and again after `pdfs refresh`.
|
||||
|
||||
**B116** (trashing a folder drops the creates queued in its subfolders) makes
|
||||
`b116/sub/deeper` and waits for Drive. With sync paused, it writes a `.part`
|
||||
file there and renames it to its final name, so its create is due but held.
|
||||
It then runs `pdfs rm b116`, and the create must be gone from the queue. It
|
||||
restores the folder with `pdfs restore` before it resumes sync. If the file
|
||||
ends up in the mount's root anyway, the case fails and moves the file to the
|
||||
trash.
|
||||
|
||||
**B117** (a moved file's queued upload keeps its path) rewrites an uploaded
|
||||
file with sync paused and moves it with `pdfs move`. `pdfs sync queue` must
|
||||
list the upload under its new path, and the journal must not show the upload
|
||||
put off with "pending operation deferred". The bytes must arrive at the new
|
||||
path with no conflict copy. Without `journalctl`, only the queue is checked.
|
||||
|
||||
**B119** (an uploaded file stays cached) writes a small file and one of a block
|
||||
plus 119 bytes, waits for the queue, and expects `pdfs ls` to list both as
|
||||
cached, so reading them does not need the network.
|
||||
|
||||
B74 was found by exactly this mechanism: the B70 case failed, and narrowing it
|
||||
produced a smaller reproduction that got its own case. The defect is fixed and
|
||||
live-validated (2026-07-26); the case now guards against its return.
|
||||
|
||||
**B79** (an on-demand device folder accepts writes) discovers its target from
|
||||
`pdfs locations --json` and skips when this machine has no mounted on-demand
|
||||
folder. It is the cheap standing guard against a *cross-mount* permission
|
||||
regression: the primary mount holds device-folder nodes too, and the queue guard
|
||||
intersects what every live inode space says about a uid, so a fail-closed
|
||||
classification in one mount denies writes in another.
|
||||
|
||||
**B34** (a viewer-role share is read-only) and **B34b** (an editor-role share
|
||||
still writes) locate their subject through `pdfs shared-with-me --json`, which
|
||||
now carries `role` and a mount-relative `path`. Both skip when the account has no
|
||||
accepted share at that role — B34 needs a **second account** to have shared a
|
||||
folder read-only, which is the one part of the sharing suite that cannot be
|
||||
self-served. B34b degrades gracefully in the other direction: a shared *file*
|
||||
gets its mode bits and `W_OK` checked but is never written to, because it is
|
||||
someone else's document.
|
||||
|
||||
B34's fourth assertion is the load-bearing one: `pending_uploads` and
|
||||
`pending_changes` must be **unchanged** across the refused writes. Mode bits
|
||||
alone would pass a fix that still admitted the write into `pending_op`, which is
|
||||
the actual harm — a background drain failing 403 forever.
|
||||
|
||||
**Synthetic `Shared with me/` directory contract** asserts the virtual directory
|
||||
enumerates, reports `0555`, and refuses `mkdir`/`rmdir`/`rename`. It skips when
|
||||
the directory is absent (an account with no accepted shares).
|
||||
|
||||
A regression case asserts against the *remote* outcome, so it must wait for the
|
||||
queue to drain before reading. Reading straight back through the mount proves
|
||||
nothing: the local attribute and content caches will happily return the bytes
|
||||
that were just written even when nothing reached Drive, which is precisely how
|
||||
B74 stayed invisible.
|
||||
|
||||
### Bugs that are still open
|
||||
|
||||
Cases named `open B<n>` check a bug that `docs/BUGS.md` still lists as open.
|
||||
While the bug reproduces, the case ends as `known`: it is listed at the end of
|
||||
the run, but does not fail it. So a known bug does not hide new failures, and
|
||||
the run notices when a fix lands, because the case then passes. Rename it to
|
||||
`regression B<n>` then, and fail it with `check` instead of `KnownIssue`. No
|
||||
bug is open at the moment. B114 to B117 started as such cases.
|
||||
|
||||
### What a run verifies
|
||||
|
||||
A full account run with `--journal-check` checks these `docs/BUGS.md` entries,
|
||||
many of them fixed but not yet verified:
|
||||
|
||||
- B48, B80, B94, B95, B98, B100, B101, B102, B111, B114, B115, B116, B117, B119
|
||||
and B121, each with its own `regression B<n>` case.
|
||||
- B118, with "application workloads" and "block boundaries", which failed on
|
||||
it. It needs Drive to be slow to list a new node, so a clean run is weak
|
||||
evidence.
|
||||
- B120, with `--journal-check`, but only in a run that goes offline while it
|
||||
writes. A mkdir made online goes to Drive at once, so only then does a
|
||||
folder land from the queue.
|
||||
- B86, with the mode-switch check in the full matrix.
|
||||
- B104, B106, B108 and B109, with the cases that found them: "rename
|
||||
patterns", "application workloads" and the account run's own setup.
|
||||
|
||||
B105, B107 and B110 are races. The suite hit each of them once, so a clean run
|
||||
is weak evidence for them. `--journal-check` reports B110's stall warning, and
|
||||
every case that writes checks for B105's conflict copies.
|
||||
|
||||
The suite does not drive:
|
||||
|
||||
- the GUI and the photo gallery (B81, B92, B93, B97);
|
||||
- states it cannot set up: a `parent_uid` cycle (B82), staged bytes that name
|
||||
no node (B96), a stuck trash refresh (B103), conflict copies left from an
|
||||
earlier start (B99), a foreign FUSE mount in the local-file index (B90),
|
||||
revisions without 4 MiB blocks (B85), and several shares from one owner
|
||||
(B112);
|
||||
- B94's hour-long expiry of a park;
|
||||
- load and failure injection (B22, B27, B30, B37, B42).
|
||||
|
||||
Check those by hand, or with the unit tests their entries name.
|
||||
|
||||
### Timeouts, reports, and hung mounts
|
||||
|
||||
A wedged FUSE operation cannot be interrupted from Python, so each case runs
|
||||
under a soft alarm (`--timeout`, default 180s) that reports the case and lets
|
||||
cleanup run, backed by a hard stop that dumps every thread's stack and exits.
|
||||
That backstop is the only way to learn *where* a mount hung. After a timeout the
|
||||
run checks whether the mount still answers a `stat` and a listing within 15
|
||||
seconds. If it does, the target goes on with its next case, so one slow stretch
|
||||
of the link does not cost every later case. If it does not, the target's later
|
||||
cases are not run: against a wedged mount they produce noise, not information.
|
||||
Cases that make many round trips by design ("application workloads", "unusual
|
||||
but legal names", "a wide directory" and "regression B98") get twice the limit.
|
||||
|
||||
```console
|
||||
scripts/fuse-acceptance.sh --live /mnt/testmount \
|
||||
--timeout 300 --report-junit results.xml --budget 30
|
||||
```
|
||||
|
||||
`--report-json` also writes every recorded observation, which is what to attach
|
||||
to a bug report. A known issue has the status `known` there; JUnit has no such
|
||||
status, so `--report-junit` reports it as skipped. `--budget SECONDS` flags cases that pass but got slower —
|
||||
B5 was a 186 ms-per-call regression that a pass/fail suite could not see.
|
||||
`--list` prints every case and which targets it runs against, and `--fail-fast`
|
||||
stops at the first failure.
|
||||
|
||||
`--journal-check` fails the run if `proton-drive.service` logged any error while
|
||||
it ran. A suite can pass every assertion while the daemon logs failures behind
|
||||
it; that has happened, and it was caught only by reading the journal by hand.
|
||||
Two warnings count as errors too: "queued write conflicts; keeping a conflict
|
||||
copy" (how B113 showed) and "a fuse worker has held the same job for a long
|
||||
time" (how B110 showed).
|
||||
|
||||
With `--live`, abandoned `pdfs-acceptance-*` roots from a crashed run are
|
||||
removed at startup, but only if they match the exact generated name and are
|
||||
over an hour old (`PDFS_ACCEPTANCE_REAP_AGE`), so a concurrent run is never
|
||||
disturbed. An account run instead uses the manifest and reaper described
|
||||
above.
|
||||
|
||||
### Automated mode matrix
|
||||
|
||||
An account run drives this matrix itself, in folders it creates. To use
|
||||
directories of your choice instead, the managed live runner accepts two
|
||||
existing, empty, unmounted directories. It
|
||||
registers both as new sync folders, waits for initial synchronization, then
|
||||
tests all four pairs in order: on-demand/on-demand, on-demand/mirror,
|
||||
mirror/on-demand, and mirror/mirror. Every transition must become idle, and an
|
||||
on-demand folder must actually appear as a mount before its contract runs.
|
||||
|
||||
```console
|
||||
mkdir -p /mnt/pdfs-test-a /mnt/pdfs-test-b
|
||||
scripts/fuse-acceptance.sh --managed-live /mnt/pdfs-test-a /mnt/pdfs-test-b
|
||||
```
|
||||
|
||||
This creates remote folders and permanently deletes those test remotes during
|
||||
cleanup. It refuses non-empty paths, existing mounts, and paths already present
|
||||
in `pdfs sync list`. Use `PDFS_ACCEPTANCE_SYNC_TIMEOUT` to extend transition
|
||||
timeouts and `PDFS_ACCEPTANCE_PDFS` to select a non-installed CLI binary, for
|
||||
example `target/debug/pdfs`.
|
||||
|
||||
After the contract, each mode pair also runs the move cases (`--list` shows
|
||||
them as `managed`). They move trees between the two folders in both
|
||||
directions, move several sources in one `pdfs move`, and pass a tree through
|
||||
My files and out again. Every case waits for Drive before and after the move.
|
||||
It then checks that the tree is only at the destination, that every byte
|
||||
arrived, and that neither side made conflict copies. The refusal cases prove
|
||||
that nothing moves and nothing is lost when:
|
||||
|
||||
- the destination already has the name;
|
||||
- a folder would move into itself;
|
||||
- a mirror source holds something Drive lacks (a symlink);
|
||||
- a file in an on-demand source is still open for writing.
|
||||
|
||||
The My files case writes a test folder into your real My files and removes it
|
||||
afterwards. It skips when the daemon reports no My files mount, or when the
|
||||
locations are on different volumes.
|
||||
|
||||
Before a mirror folder switches to on-demand, the runner writes a new file into
|
||||
it and syncs. Once the folder is mounted, the file must be listed with its
|
||||
bytes (B86). Each switch reports a `regression B86` result under a separate
|
||||
`mode switches` target. The bug only shows in a folder that was mounted in an
|
||||
earlier pairing, because that mount's listing is stale. That happens in the
|
||||
third pairing, so `--quick` runs the check but does not replay the bug.
|
||||
|
||||
In every pairing, each folder must still hold the file the runner wrote into it
|
||||
at setup, byte for byte. These checks report under `mode switches` as well. A
|
||||
switch to on-demand frees the local copy, so these reads go to Drive. If the
|
||||
link drops, the daemon fails such a read with EIO once its two-minute read
|
||||
budget runs out. That is correct, so the runner waits until the daemon reports
|
||||
online again, tries once more and notes the retry under the result. Only the
|
||||
second try decides the result.
|
||||
|
||||
### Durability drill
|
||||
|
||||
`--durability` restarts `proton-drive.service` in the middle of the suite and
|
||||
re-verifies the bytes written before it. `staging/` and `recovery/` can hold the
|
||||
only copy of a file whose upload has not finished, so this is the case that
|
||||
proves the queue is persistent rather than in-memory. It is opt-in because it
|
||||
interrupts a running daemon; `PDFS_ACCEPTANCE_UNIT` selects a different unit.
|
||||
| Option | Effect |
|
||||
|---|---|
|
||||
| `--report-json PATH` | Every result and recorded observation. Attach it to a bug report |
|
||||
| `--report-junit PATH` | JUnit XML. `known` is reported as skipped |
|
||||
| `--budget SECONDS` | Flags cases that pass but take longer (`PDFS_ACCEPTANCE_BUDGET`) |
|
||||
| `--journal-check` | Fails the run if `proton-drive.service` logged an error during it, or the warnings "queued write conflicts; keeping a conflict copy" and "a fuse worker has held the same job for a long time" |
|
||||
| `--durability` | Restarts the service mid-suite and checks the bytes written before it. Proves the queue survives a restart. `PDFS_ACCEPTANCE_UNIT` names another unit |
|
||||
| `--fail-fast` | Stops at the first failure |
|
||||
| `--list` | Prints every case and its targets |
|
||||
|
||||
### Selecting cases
|
||||
|
||||
During development, `PDFS_ACCEPTANCE_ONLY` runs tests whose descriptive name
|
||||
contains the supplied text, ignoring case (for example
|
||||
`PDFS_ACCEPTANCE_ONLY=namespace`). Separate several texts with commas to run
|
||||
every case that matches any of them (`PDFS_ACCEPTANCE_ONLY=B116,B117`). A
|
||||
release acceptance run must leave it unset so the complete contract executes.
|
||||
`PDFS_ACCEPTANCE_ONLY` runs the cases whose name contains the text, ignoring case. Separate several
|
||||
with commas (`PDFS_ACCEPTANCE_ONLY=B116,B117`). A filter that matches no case at all fails, as the
|
||||
typo it is. A release run leaves it unset.
|
||||
|
||||
A filter naming a live-only case (every `regression B<n>` is one) selects nothing
|
||||
in the account-free reference run, which is not an error — that target simply has
|
||||
nothing to do. Only a filter matching *no case at all* fails, as the typo it is.
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `PDFS_ACCEPTANCE_PDFS` | The `pdfs` binary to use, for example `target/debug/pdfs` |
|
||||
| `PDFS_ACCEPTANCE_SYNC_TIMEOUT` | Seconds a sync folder may take to settle after a change of mode |
|
||||
| `PDFS_ACCEPTANCE_REAP_AGE` | Age in seconds after which a stray `pdfs-acceptance-*` folder is removed (default 3600) |
|
||||
| `PDFS_ACCEPTANCE_CONVERGENCE` | `1` checks that several `--live` paths show the same bytes |
|
||||
| `PDFS_ACCEPTANCE_MIN_MIBPS`, `PDFS_ACCEPTANCE_MIN_OPS` | Throughput floors |
|
||||
| `PDFS_ACCEPTANCE_UNIT` | The systemd unit `--durability` restarts |
|
||||
|
||||
Passing more paths runs the same filesystem suite independently against each.
|
||||
The first path must be the FUSE mount under test; later paths may be another
|
||||
FUSE mount or a normal local mirror filesystem:
|
||||
## Before a release
|
||||
|
||||
```console
|
||||
PDFS_ACCEPTANCE_SYNC_TIMEOUT=180 \
|
||||
scripts/fuse-acceptance.sh --live /mnt/on-demand-testmount /mnt/testmount
|
||||
```
|
||||
1. The quality gates and the simulation runs pass in CI.
|
||||
2. A full account run with `--journal-check` passes on a test account:
|
||||
`scripts/fuse-acceptance.sh --journal-check`.
|
||||
3. Fixes marked *unverified* in [BUGS.md](BUGS.md) that a run now covers are marked verified.
|
||||
|
||||
If two paths really are views of the **same remote folder**, enable an
|
||||
additional byte-for-byte convergence check explicitly:
|
||||
|
||||
```console
|
||||
PDFS_ACCEPTANCE_CONVERGENCE=1 \
|
||||
scripts/fuse-acceptance.sh --live /mnt/first-view /mnt/second-view
|
||||
```
|
||||
|
||||
Do not enable that flag merely because two sync folders belong to the same
|
||||
account. Their configured remote UIDs must be identical.
|
||||
|
||||
Normally the live suite applies the same API contract independently to each
|
||||
supplied mount. In convergence mode it exercises the primary once and verifies
|
||||
that the resulting bytes appear through every secondary view. A pass is
|
||||
strong evidence for those paths, not a blanket data-loss guarantee. Before a
|
||||
release, also run the recovery drills in `docs/RECOVERY.md` and inspect
|
||||
`docs/BUGS.md` for live-verification items.
|
||||
|
||||
The runner accepts already-mounted paths instead of starting a daemon. This
|
||||
keeps authentication and keyring setup explicit and prevents it from silently
|
||||
reusing or replacing the normal application state directory.
|
||||
What no automated run covers yet is listed under
|
||||
[Verification still owed](ROADMAP.md#verification-still-owed).
|
||||
+37
-9
@@ -69,8 +69,24 @@ 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.
|
||||
|
||||
**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
|
||||
@@ -113,29 +129,41 @@ pdfs refresh Documents
|
||||
**"Permission denied" when writing** — the item is in a share where you are a viewer, or under
|
||||
`Shared with me/`, which is read-only as a folder. Check your role with `pdfs shared-with-me`.
|
||||
|
||||
**A program says "Operation not supported"** — Proton Drive cannot store symbolic links, hard
|
||||
links, device files or FIFOs. The error is intentional. Use a synced folder for
|
||||
projects that need them, or exclude those paths.
|
||||
**`ln` or another program fails with "Operation not permitted"** — Proton Drive cannot store
|
||||
symbolic links, hard links, device files or FIFOs, so creating one fails at once. The error is
|
||||
intentional. Use a synced folder for projects that need them, or exclude those paths.
|
||||
|
||||
## Uploads and sync
|
||||
|
||||
**Something is stuck uploading** — list the queue and retry:
|
||||
**A change shows here but not on other devices** — changes are recorded on this computer first
|
||||
and sent from a queue. List what is still waiting, and what needs attention:
|
||||
|
||||
```bash
|
||||
pdfs sync queue
|
||||
pdfs sync issues
|
||||
```
|
||||
|
||||
The app shows the same on **Sync → Overview**. A change waits while the service is offline or
|
||||
sync is paused (`pdfs status`), and backs off after a failure. To retry at once:
|
||||
|
||||
```bash
|
||||
pdfs sync retry # every failed operation
|
||||
pdfs sync retry 42 # one operation
|
||||
```
|
||||
|
||||
The app shows the same list on **Sync → Overview**. An operation that keeps failing shows its
|
||||
error there. A change Proton Drive will not take can be dropped instead, which undoes it here;
|
||||
save a file's content first if you want to keep it:
|
||||
A change Proton Drive refuses (storage full, access taken away, folder gone) is listed by
|
||||
`pdfs sync issues` with what to do. It stays queued until you fix the cause or drop it. Dropping
|
||||
it undoes it here; save a file's content first if you want to keep it:
|
||||
|
||||
```bash
|
||||
pdfs sync export 42 ~/Desktop/
|
||||
pdfs sync discard 42
|
||||
```
|
||||
|
||||
If you suspect the local-first behaviour of 3.0.0 itself, `"local_first": false` in `config.json`
|
||||
makes the Proton Drive folder wait for Proton Drive again while online (see
|
||||
[CONFIGURATION.md](CONFIGURATION.md#local_first)), and please report the problem.
|
||||
|
||||
**A file in a synced folder does not upload** — check that:
|
||||
|
||||
- it is not matched by `.pdfsignore` or `ignore_patterns`;
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
# UI/UX audit and improvement plan
|
||||
|
||||
Audit date: 2026-09-23. Scope: `pdfs-gui` (all pages, dialogs, photo viewer), `pdfs-tray`, and the
|
||||
backend capabilities the UI would need. File references are to `crates/pdfs-gui/src/app/` unless
|
||||
stated otherwise.
|
||||
|
||||
## 1. Summary
|
||||
|
||||
The client is functionally rich but the UI is built page by page, and it shows:
|
||||
|
||||
- **No sync control surface.** There is no way to pause or resume sync, no list of what is stuck or
|
||||
failing, and no conflict overview. The backend has no pause at all, no per-op listing, and no
|
||||
per-file conflict record (conflicts exist only as `(sync-conflict <ts>)` files plus activity rows).
|
||||
The GUI reads `online`, `pending_uploads` and `pending_changes` from `Status` and ignores
|
||||
`failing_ops`, `failing_error`, `parked_uploads` and `staged_*`.
|
||||
- **Information architecture is scattered.** Live transfers are a group on *Settings* while the
|
||||
*Activity* page is history only. *Locations* and *Computers* point at each other. Import lives under
|
||||
Settings but is launched from Gallery and returns to Settings. Sign-out, quota, mount status,
|
||||
pinned files, import and developer info all share the Settings page.
|
||||
- **Chrome is non-standard.** Every page draws its own title and toolbar row under an almost empty
|
||||
`AdwHeaderBar` whose title is always "Proton Drive". Two stacked bars, wasted height, no current
|
||||
folder in the title.
|
||||
- **Icons and wording drift.** Upload uses `document-send-symbolic` (reads as "send"), offline uses
|
||||
stars (reads as "favorite"), trash icons mean "stop syncing", "remove device" and "remove
|
||||
bookmark". "Keep offline" / "Unpin" / "Remove offline copy" / "Available offline" are one concept.
|
||||
"Computers" vs "device", "Gallery" vs "Photos", "mount service" vs "daemon" vs "Connect".
|
||||
- **Theming is hardcoded.** `PROTON_PURPLE` overrides the system accent; badge, viewer and tile
|
||||
colours are hex/rgba literals and ignore light, dark and high-contrast modes.
|
||||
- **Several visible bugs** (section 9).
|
||||
|
||||
## 2. Design principles
|
||||
|
||||
1. **Follow GNOME HIG and current libadwaita.** The system has libadwaita 1.9; the crate is pinned to
|
||||
feature `v1_5`. Raise it to at least `v1_7` to get `adw::Spinner`, `ToggleGroup`,
|
||||
`InlineViewSwitcher`, `ButtonRow`, `WrapBox`, `BottomSheet`, and (1.8) `ShortcutsDialog`.
|
||||
2. **Sync state is first class.** The user must always see: up to date / syncing / paused / offline /
|
||||
needs attention, and be one click from pausing or from the list of problems.
|
||||
3. **One term, one icon, one place** per concept (glossary in section 8).
|
||||
4. **Secondary and destructive actions go into menus.** Rows get at most one visible suffix action
|
||||
plus a `view-more-symbolic` menu. Destructive actions are red inside the menu and in the confirm
|
||||
dialog, not as filled buttons in headers.
|
||||
5. **Never block on the network visibly.** Keep old content on screen while reloading, show a header
|
||||
spinner, never flash the whole page to a "Loading…" status page.
|
||||
6. **Respect the system.** Use the system accent by default (brand purple stays on login, app icon
|
||||
and an opt-in toggle). Use named colours (`@success_color`, `@warning_color`, `@error_color`).
|
||||
|
||||
## 3. New information architecture
|
||||
|
||||
### Sidebar
|
||||
|
||||
```
|
||||
Proton Drive (header: app name)
|
||||
My files
|
||||
Photos
|
||||
Shared with me (badge: pending invitations)
|
||||
Shared by me
|
||||
Computers
|
||||
Trash
|
||||
─────────────
|
||||
Sync (badge: issue count, icon reflects state)
|
||||
───────────── footer
|
||||
[●] Up to date · Pause ← sync status strip, click opens Sync
|
||||
▓▓▓░░░ 269 GiB of 1.6 TiB ← quota
|
||||
(avatar) user@proton.me ⋮ ← account menu: Preferences, Sign out…
|
||||
```
|
||||
|
||||
- **Remove** sidebar rows: *Locations* (merged into Sync), *Activity* (becomes the Sync "History"
|
||||
view), *Settings* (becomes an `AdwPreferencesDialog`, `Ctrl+,`).
|
||||
- **Rename** *Gallery* to *Photos* and *Shared* to *Shared by me*.
|
||||
- Primary menu: Preferences, Keyboard Shortcuts, About. The About dialog's `debug_info` gets app
|
||||
version, user agent, daemon status and config path (replacing the "Developer" group).
|
||||
|
||||
### Sync page (new, replaces Locations + Activity + Settings→Activity)
|
||||
|
||||
Top: an `AdwBanner`-style status card with state icon, one-line summary ("Syncing 12 files · 3.4
|
||||
MB/s", "Paused until you resume", "2 files need attention") and one primary button: **Pause** /
|
||||
**Resume** (split button with "Pause for 1 hour / until tomorrow / until resumed").
|
||||
|
||||
Below, an `InlineViewSwitcher` with four views:
|
||||
|
||||
1. **Issues** (default when non-empty): conflicts, failing uploads (`last_error`, attempts, next
|
||||
retry), folders in `error`/`conflict` state, parked uploads older than N minutes. Each row has a
|
||||
primary fix action (Resolve…, Retry now) and a menu (Show in folder, Discard change…).
|
||||
2. **Transfers**: in-flight uploads and downloads with progress, speed, cancel; background jobs
|
||||
(thumbnail build, Takeout import, re-date) with their own cancel.
|
||||
3. **Folders**: the old Locations list. Row: path, mode as a dropdown suffix (Synced / On-demand, with
|
||||
confirm when going on-demand deletes local copies), state subtitle, `Sync now` and a menu (Open,
|
||||
Change location…, Pause this folder, Stop syncing…). "Add folder…" as a `ButtonRow` at the end.
|
||||
The primary mount appears first and is not removable.
|
||||
4. **History**: the old Activity feed, with a kind filter, failures in `@error_color`, rows
|
||||
activatable (open or reveal the target), and "Clear history".
|
||||
|
||||
### Preferences dialog (slimmed Settings)
|
||||
|
||||
| Page | Contents |
|
||||
|---|---|
|
||||
| General | Start on login; Mount location (path + Change…); Notifications (on sync issues, on import done) |
|
||||
| Storage | Cache usage bar; Cache budget (spin row, in GB, "Unlimited" switch instead of magic 0); Clear cache…; Offline files (count + "Manage" → Sync › Folders filter or a sub-page) |
|
||||
| Sync | Ignore patterns (editable list); Conflict clean-up: Off / Report / Remove identical copies (`conflict_sweep`); later: bandwidth limits |
|
||||
| Advanced | Open-with rules, search prompt config (only if kept in GUI) |
|
||||
|
||||
Moved out: account row and Sign out (sidebar account menu, with confirm), quota (sidebar footer),
|
||||
mount status (sidebar status strip), live transfers (Sync), Google Photos import (Photos page menu),
|
||||
version/user agent (About → Debug info).
|
||||
|
||||
## 4. Backend work required
|
||||
|
||||
Everything below is new; nothing like it exists today.
|
||||
|
||||
### B-1 Pause and resume (global, then per folder)
|
||||
|
||||
- `Request::SetSyncPaused { paused: bool, until: Option<i64> }`, persisted (config key
|
||||
`sync_paused_until` or a DB setting) so a pause survives restarts; `Response::Status` gains
|
||||
`paused` and `paused_until`.
|
||||
- Drain: `run_pending_drain` (`pdfs-fuse/src/drain.rs:265`) already releases claimed ops while
|
||||
offline; gate on `paused` at the same point and reuse the wake path.
|
||||
- Sync engine: add `SyncMsg::Pause/Resume` or gate `reconcile_all` / `reconcile_folder`; the 120 s
|
||||
poll and watcher keep recording, they just do not apply.
|
||||
- FUSE reads must keep working while paused (on-demand hydration is a read, not a sync); document that
|
||||
pause means "no uploads, no mirror reconcile".
|
||||
- Per folder later: `sync_folder.paused` column, skipped in `reconcile_all`.
|
||||
- Bandwidth limits later: token bucket in `CountingReader` / `CountingWriter`
|
||||
(`pdfs-fuse/src/transfers.rs`).
|
||||
|
||||
### B-2 Pending ops and transfers
|
||||
|
||||
- `Request::ListPendingOps` → id, kind, path/name, attempts, `last_error` (classified as
|
||||
`ErrorKind`), `next_attempt_at`, parked flag, staged bytes.
|
||||
- `Request::RetryOp { id }` (set `next_attempt_at = now`, wake drain) and `RetryAllFailing`.
|
||||
- `Request::DiscardOp { id }` for a create/revision whose bytes should be dropped. Must save the
|
||||
staged blob to a local recovery folder first; never silent data loss (see `docs/RECOVERY.md`).
|
||||
- `TransferItem` gains a stable id and `Request::CancelTransfer { id }`.
|
||||
- Per-folder last error message on `SyncFolderInfo` (today the text only exists in the activity feed).
|
||||
|
||||
### B-3 Conflicts
|
||||
|
||||
- `Request::ListConflicts`: walk nodes whose name parses with `sweep.rs` `conflict_base_name`, for
|
||||
the mount and every mirror folder. Return copy path, original path (if it exists), both sizes and
|
||||
mtimes, whether content is identical (size + `content_sha1`, as the sweep already proves), and
|
||||
which side is local vs remote when known.
|
||||
- Optionally a `conflict` table written by `keep_as_conflict_copy` / `preserve_conflict_copy` so the
|
||||
origin (drain vs mirror, device) is recorded instead of inferred from names.
|
||||
- `Request::ResolveConflict { copy, keep: Original | Copy | Both }`:
|
||||
- Original: trash the copy (re-verify like the enforcing sweep does).
|
||||
- Copy: upload the copy's content as a new revision of the original, then trash the copy (the old
|
||||
content stays in version history, so this is reversible).
|
||||
- Both: rename the copy to a user-chosen name, drop the `(sync-conflict …)` stamp.
|
||||
- Activity rows for each resolution.
|
||||
|
||||
## 5. Shell and visual polish (all pages)
|
||||
|
||||
- **Per-page header bars.** Give each stack page its own `adw::ToolbarView` + `adw::HeaderBar` (or
|
||||
inject page widgets into the shared bar on page switch). Title = page name, or the current folder
|
||||
in My files. Page actions move into the header; the extra toolbar row disappears.
|
||||
- **One page skeleton.** Same margins everywhere (today 12 vs 18), same `Clamp` rule (lists that are
|
||||
settings-like clamp at 720, file/photo grids are full width), same status pages (`compact`,
|
||||
`vexpand`, crossfade), same Retry semantics (Retry reloads; "Start Proton Drive" is a separate,
|
||||
explicitly labelled button when the service is down).
|
||||
- **Loading.** `adw::Spinner` in the header; keep the old model until the new one lands; status page
|
||||
only on first load.
|
||||
- **Theme.** Drop the global `accent_*` override; offer "Use Proton purple accent" in Preferences.
|
||||
Replace hex colours with named colours; test light, dark and high contrast.
|
||||
- **Move the 70-line CSS string** in `main.rs:232` to `resources/style.css` in the gresource
|
||||
(`style-dark.css` if needed), so it can be edited and linted.
|
||||
- **Shortcuts.** Replace the hand-rolled cheatsheet with `adw::ShortcutsDialog` (or
|
||||
`GtkShortcutsWindow` before 1.8) listing all bindings, including F5/Ctrl+R and the viewer keys.
|
||||
- **Toasts vs dialogs.** Dialogs report their own errors inline (versions, share, add bookmark); a
|
||||
toast behind a modal is invisible. Pluralise properly (`ngettext`-ready helpers, no "item(s)").
|
||||
- **i18n groundwork.** Wrap user strings in a `gettext!`-style macro now, even without translations,
|
||||
so later localisation is mechanical.
|
||||
|
||||
## 6. Page-by-page plan
|
||||
|
||||
### My files
|
||||
|
||||
Header: `[‹ ›] Path bar (breadcrumbs, scroll to end, Ctrl+L to edit) … [search] [view ▾] [+ New ▾]`
|
||||
|
||||
- **New ▾** (`list-add-symbolic`, suggested): New folder (Ctrl+Shift+N), Upload files (Ctrl+U),
|
||||
Upload folder. Replaces three separate icons, fixes the "send" icon.
|
||||
- **View ▾** split button: Grid/List (Ctrl+1/2), Sort by Name/Size/Modified, ascending/descending,
|
||||
Folders first, zoom slider. Persist choice (config or GSettings).
|
||||
- **Folder ⋮ menu** (in header or background context menu): Refresh, Build thumbnails, Make folder
|
||||
available offline, Open in file manager, Share folder…. Build thumbnails leaves the main toolbar.
|
||||
- **Navigation.** Back/forward history (Alt+←/→), Up (Alt+↑); searching then pressing Back clears
|
||||
the search consistently.
|
||||
- **Context menus** as `gtk::PopoverMenu` from `gio::Menu` + actions: keyboard navigable, Menu /
|
||||
Shift+F10, same action set as details pane. Add a background menu. Order: Open, Open with…, Play
|
||||
(media) · Available offline (check item) · Share…, Copy link · Rename…, Move to…, Versions… ·
|
||||
Move to Trash (destructive).
|
||||
- **Offline state.** One concept "Available offline", toggle semantics. Badges: cloud-only = no
|
||||
badge or subtle `cloud-outline`; downloaded = check; available offline = pin/`folder-download`
|
||||
style filled icon. Use named colours. Folders can be made available offline too, or the toggle is
|
||||
hidden for them (fix `details.rs:259`).
|
||||
- **List view.** Sortable, resizable columns: Name, Status, Size, Modified (date + time), and
|
||||
Location for search results. Rubberband selection. One selection model shared between views.
|
||||
- **Details pane.** On wide windows a real side pane (breakpoint, not overlay); header bar with
|
||||
close; real thumbnail; sections: Info, Offline, Sharing (people count, link), Versions (latest 3 +
|
||||
"All versions…"). Same actions as the context menu.
|
||||
- **Selection bar** (≥1 selected, not ≥2): count + size, Available offline, Move to…, Share (single),
|
||||
Move to Trash; Select all (Ctrl+A). Drop the confirm dialog for trash since Undo exists (trash is
|
||||
reversible), or keep confirm and drop Undo — not both.
|
||||
- **Drag and drop.** Drop external files/folders to upload (reuse Takeout's `FileList` target), drag
|
||||
the whole selection, highlight drop targets, breadcrumbs accept drops, Undo toast on move.
|
||||
- **Move to…** a folder-picker dialog (tree/list of Drive folders) instead of a free-text path.
|
||||
- **Rename** pre-selects the stem, not the extension; drop the redundant "Rename “x”." body.
|
||||
- **Search.** Scope toggle (This folder / Everywhere), "Showing first 200 results" note, Location
|
||||
column, correct cached badges (search hits hardcode `cached: false`).
|
||||
- **Upload feedback.** Toast "Uploading 3 items" with "View" → Sync › Transfers.
|
||||
|
||||
### Photos (was Gallery)
|
||||
|
||||
- Header: title "Photos", `InlineViewSwitcher` Timeline | Albums in the header, Select toggle,
|
||||
`+ Upload` (fix: use `adw::ButtonContent`, today `.icon_name()` overwrites `.label()` at
|
||||
`photos.rs:426-432`), ⋮ menu: Import from Google Photos…, Refresh, Rebuild library.
|
||||
- Filters: a single filter dropdown/`ToggleGroup` (All, Photos, Videos, RAW, Favorites) instead of
|
||||
two segmented rows where "Photos" appears twice. Counts follow the active filter.
|
||||
- **Month scrubber** on the right edge that *jumps* (not filters); the month dropdown becomes a real
|
||||
"Filter by date" if kept.
|
||||
- Infinite scroll with prefetch (no "Load more" pill; page size ≥ 200; also trigger when the first
|
||||
page does not fill the viewport).
|
||||
- Tiles: `ContentFit::Cover` (crop) in justified rows, known aspect ratio from metadata to prevent
|
||||
reflow, placeholder as a tinted card of the final size, subtle hover (no 1.06 scale with heavy
|
||||
shadow on a 2 px gap grid), last row justified up to a max stretch.
|
||||
- Context menu on tiles: Open, Favorite, Add to album, Download/Save copy, Show in My files, Move to
|
||||
Trash. Selection: Shift range, Ctrl+A, bulk Favorite, Add to album, Download.
|
||||
- Empty states per filter ("No favorites yet", "No videos in June 2024") instead of the global
|
||||
"No photos yet" with upload buttons.
|
||||
- Import: runs show a banner on Photos ("Importing from Google Photos — 1,204 of 5,000 · View") and
|
||||
Back returns to where the user came from.
|
||||
- **Albums:** Create, rename, delete, remove from album (not trash). Fix state bugs: filter row
|
||||
hidden on the album grid, subtitle count, scroll position kept when switching back.
|
||||
- **Viewer:** `adw::Window` (or full-window overlay), controls auto-hide after 2 s idle, zoom/pan
|
||||
(pinch, Ctrl+scroll, double-click), info panel beside the image instead of over the top bar,
|
||||
pages beyond the loaded 60, counter against the real total, videos play inline with
|
||||
`gtk::Video`, trash with the same confirm/undo rule as the grid, real filename in "Save a copy",
|
||||
success toast, "Open with…" app chooser, favorite tooltip reflects state, readable dates.
|
||||
|
||||
### Shared with me / Shared by me
|
||||
|
||||
- Shared with me: header title shows the drill-down path, with back; invitations as a banner at
|
||||
the top ("2 invitations · Review") opening a list, Reject confirmed; bookmarks become a separate
|
||||
view or section via a switcher. Rows: Open, Save to My files, Available offline where supported.
|
||||
- Shared by me: rows activatable (open), suffix "Manage" becomes a menu with Copy link, Manage
|
||||
access…, Stop sharing….
|
||||
- Share dialog: inline validation, loading spinner, confirm "Remove access" and "Delete link",
|
||||
human role names, link expiry.
|
||||
|
||||
### Computers
|
||||
|
||||
- Show the real device name for "This computer" with rename inline.
|
||||
- Other computers: one suffix menu (Rename, Use this identity…, Remove…). Remove uses a destructive
|
||||
confirm that requires typing the device name since it deletes backups.
|
||||
- "Restore folders" becomes a per-device action ("Restore to this computer…") with an inline-empty
|
||||
state when nothing is restorable, and moves the "Add folder" responsibility fully to Sync.
|
||||
- Terminology: "computer" everywhere in UI copy.
|
||||
|
||||
### Trash
|
||||
|
||||
- Same skeleton as other list pages (clamped `boxed-list`), sort by deleted date, multi-select with
|
||||
Restore / Delete permanently, "Empty Trash…" moved into a header ⋮ menu or kept as a flat button
|
||||
with a destructive confirm, proper plurals, Retry on error.
|
||||
|
||||
### Login
|
||||
|
||||
- Human error messages mapped from error kinds, spinner in the button, "Create account" and "Forgot
|
||||
password" links, recovery-code hint in 2FA, cancelling CAPTCHA returns to the form with a message.
|
||||
|
||||
## 7. Tray
|
||||
|
||||
- Symbolic icon variants: `pdfs-synced`, `pdfs-syncing`, `pdfs-paused`, `pdfs-offline`,
|
||||
`pdfs-attention`; tooltip with the one-line state; `NeedsAttention` status for issues.
|
||||
- Menu: status line · "N issues — View" (opens Sync › Issues) · Pause sync ▸ (1 h, until tomorrow,
|
||||
until resumed) / Resume · Open Proton Drive · Open folder · "Sign in…" when logged out (no
|
||||
"Connect") · Stop Proton Drive (confirm) · "Hide tray icon" instead of "Quit" (daemon keeps
|
||||
running and the label must not suggest otherwise).
|
||||
|
||||
## 8. Glossary (one term, one icon)
|
||||
|
||||
| Concept | UI term | Icon |
|
||||
|---|---|---|
|
||||
| Root of Drive | My files | `folder-symbolic` |
|
||||
| Background service | Proton Drive (running / stopped) — never "daemon" or "mount service" in UI | — |
|
||||
| Cloud-only file | Online only | none / `cloud-outline` |
|
||||
| Pinned file | Available offline | pin/download-filled custom icon, not stars |
|
||||
| Upload | Upload files / Upload folder | `document-upload`-style arrow up, custom pair |
|
||||
| Folder sync mode | Synced / Online only | — |
|
||||
| Where My files appears on disk | Proton Drive folder — never "mountpoint" in UI | — |
|
||||
| Stop syncing a folder | Stop syncing… | `media-playback-stop-symbolic` in a menu, not trash |
|
||||
| Remove a computer | Remove computer… | destructive, in menu |
|
||||
| Conflict copy | Conflict | `dialog-warning-symbolic` + warning colour |
|
||||
| Failure | Failed / Needs attention | `dialog-error-symbolic` + error colour |
|
||||
| Pause | Pause sync / Resume sync | `media-playback-pause-symbolic` / `-start-` |
|
||||
| Photos section | Photos | `image-x-generic-symbolic` |
|
||||
| Sharing | Shared by me / Shared with me | distinct icons (`emblem-shared` / `folder-publicshare`) |
|
||||
|
||||
Spelling: US English throughout ("Favorite", "color"), matching the `pdfs favorite` command and
|
||||
Proton's own apps. Title Case for
|
||||
dialog headings and buttons in dialogs, Sentence case for menu items and rows (HIG).
|
||||
|
||||
## 9. Bugs found during the audit (quick wins)
|
||||
|
||||
1. Photos Upload pill loses its label (`photos.rs:426-432`); same for viewer "Show on map"
|
||||
(`photo_viewer.rs:594-598`).
|
||||
2. Takeout Back always goes to Settings, sidebar highlights Settings when opened from Photos
|
||||
(`main.rs:779`, `takeout.rs:131-136`).
|
||||
3. Thumbnail-build completion message computed then hidden (`browser.rs:1632-1667`).
|
||||
4. Search results show cached files as cloud-only (`browser.rs:2344`).
|
||||
5. Details "Available offline" switch shown for folders (`details.rs:259`).
|
||||
6. Grid and list keep separate selections; details pane can describe a hidden-view item.
|
||||
7. F2 silently does nothing with multiple items selected.
|
||||
8. Filter changes on the Albums grid swap to the timeline while the Albums toggle stays active
|
||||
(`photos.rs:958-997`).
|
||||
9. Subtitle "60 photos" next to "All 1,416" (loaded count vs total, `photos.rs:2007-2015`); viewer
|
||||
counter "12 of 60".
|
||||
10. Viewer info panel covers Close/Trash/Save (`photo_viewer.rs:588` vs `648`).
|
||||
11. Locations mode switch and removal reload Computers, not Locations; a rejected mode switch stays
|
||||
flipped until the next tick (`devices.rs:612`, `devices.rs:809`, `locations.rs:374`).
|
||||
12. Going on-demand deletes local copies without confirmation.
|
||||
13. Retry on Not-connected silently restarts the systemd unit; Locations' Retry does not.
|
||||
14. Toasts from Versions/Share dialogs land behind the modal.
|
||||
15. "Emptied trash —" dangling dash, "item(s)", "archive(s)", raw revision id as version title,
|
||||
raw lowercase link role.
|
||||
16. Bulk context menu shows a separator directly under the header when a folder is selected.
|
||||
17. Sign out, Reject invitation, Remove access, Delete link, tray Disconnect: no confirmation.
|
||||
18. Stale doc comments (`devices.rs:46-48`, `locations.rs:329-331`, `albums.rs:4`,
|
||||
`browser.rs:1155`).
|
||||
|
||||
## 10. Phased roadmap
|
||||
|
||||
Each phase is shippable on its own. Estimates are rough, for one developer.
|
||||
|
||||
| Phase | Content | Crates | Size |
|
||||
|---|---|---|---|
|
||||
| 0 Quick wins | Section 9 bugs, glossary wording, icon swaps (upload, offline, stop syncing), confirmations, plurals | gui, tray | S (2–3 days) |
|
||||
| 1 Shell | libadwaita feature bump, per-page header bars, sidebar restructure + footer (status, quota, account), Preferences dialog, About debug info, theme/colour cleanup, CSS to gresource, shortcuts dialog | gui | M (1 week) |
|
||||
| 2 Sync hub + pause | B-1 global pause, B-2 list/retry pending ops, Sync page (status card, Issues, Transfers, Folders, History), tray states and pause menu, GUI uses `failing_*` / `parked_*` | core, fuse, gui, tray, cli | L (1.5–2 weeks) |
|
||||
| 3 Conflicts | B-3 list + resolve, Issues rows with a compare/resolve dialog (both versions side by side: size, date, device, preview for images/text), CLI `pdfs conflicts` | core, fuse, gui, cli | M–L |
|
||||
| 4 File browser | Header layout, New menu, sort/persisted view, PopoverMenu context menus, details side pane, DnD upload, folder picker for Move, search scope | gui | L |
|
||||
| 5 Photos | Rename, header switcher, filters, infinite scroll + scrubber, tile layout, context menu, album management, viewer rewrite (auto-hide, zoom, inline video) | gui (+ core for album mutations) | L |
|
||||
| 6 Rest | Shared pages, Computers, Trash, Login, per-folder pause, bandwidth limits, i18n wrapping | all | M |
|
||||
|
||||
Phase 2 is where the user-reported gaps (pause, resume, stuck items) get closed; phase 3 closes the
|
||||
conflict overview. Phases 0–1 fix most of the "unpolished" impression cheaply and should come first
|
||||
because later phases build on the new header/page skeleton.
|
||||
|
||||
### Verification per phase
|
||||
|
||||
- `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets -- -D warnings`,
|
||||
`cargo test --workspace`.
|
||||
- Backend phases: DB migration tests in `crates/pdfs-core/src/db/tests.rs` (paused state, conflict
|
||||
table if added), drain tests proving no op is claimed while paused, resolve-conflict tests proving
|
||||
the losing side stays recoverable (trash or version history).
|
||||
- GUI phases: before/after screenshots in `images/` (light and dark), keyboard-only walkthrough of
|
||||
each page, narrow-window check (sidebar collapse breakpoint).
|
||||
+46
-10
@@ -7,6 +7,7 @@ How Proton Drive for Linux works and how to use it day to day. For installation
|
||||
- [My files](#my-files)
|
||||
- [Keeping files on this computer](#keeping-files-on-this-computer)
|
||||
- [Syncing local folders](#syncing-local-folders)
|
||||
- [Changes waiting for Proton Drive](#changes-waiting-for-proton-drive)
|
||||
- [Conflicts](#conflicts)
|
||||
- [Pausing and limiting sync](#pausing-and-limiting-sync)
|
||||
- [Photos](#photos)
|
||||
@@ -33,7 +34,7 @@ the service to do things over a local socket:
|
||||
|
||||
Your Drive, your file names and your photos are end-to-end encrypted by Proton. On this computer,
|
||||
the service must decrypt what it shows you, and it keeps a decrypted cache and index on disk. Use
|
||||
full-disk or home-directory encryption. [ARCHITECTURE.md §8](ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext)
|
||||
full-disk or home-directory encryption. [ARCHITECTURE.md §10](ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext)
|
||||
lists exactly what is stored.
|
||||
|
||||
The app uses these words consistently, and so does this guide:
|
||||
@@ -54,9 +55,11 @@ downloads only when a program reads it:
|
||||
|
||||
- **Reads fetch only what is needed.** A video player that seeks, or a tool that reads a file
|
||||
header, downloads only those parts. Media streams while it plays.
|
||||
- **Writes are safe offline.** When a program closes a file it wrote, the service stores the new
|
||||
content on disk and queues the upload. The queue survives network loss, restarts and crashes.
|
||||
`pdfs sync queue` lists what has not reached Proton Drive yet.
|
||||
- **Changes work at local speed.** Creating, renaming, moving and deleting files and folders
|
||||
returns at once, online or offline. When a program closes a file it wrote, the new content is
|
||||
stored on disk. Every change is queued and sent to Proton Drive in the background; the changes
|
||||
to one file are sent in the order you made them. The queue survives network loss, restarts and
|
||||
crashes. See [Changes waiting for Proton Drive](#changes-waiting-for-proton-drive).
|
||||
- **Recently read content is cached** on disk up to the cache size limit (5 GiB by default) and
|
||||
evicted oldest first.
|
||||
- **Items shared with you** appear under `Shared with me/` inside the Proton Drive folder. Shares
|
||||
@@ -93,7 +96,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
|
||||
|
||||
@@ -143,6 +148,39 @@ 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
|
||||
Proton Drive. `pdfs status`, the tray and **Sync → Overview** count what is still waiting, and
|
||||
`pdfs sync queue` lists it.
|
||||
|
||||
Sometimes Proton Drive refuses a change: the storage is full, a share was taken away, the folder
|
||||
is gone, the folder holds too many items, or Drive does not accept a name. The change then needs
|
||||
your attention. It shows at once in **Sync → Overview**, in the tray, and in `pdfs sync issues`,
|
||||
with what it means and what you can do. A file or folder with such a change carries a warning
|
||||
badge in **Files**; hover it to see why.
|
||||
|
||||
Nothing is deleted. The change stays queued and is retried. You can:
|
||||
|
||||
- **Retry** it now, from the same list or with `pdfs sync retry`, for example after freeing space.
|
||||
- **Export** a file's content to keep your version (`pdfs sync export <id> <file>`).
|
||||
- **Discard** the change (`pdfs sync discard <id>`). It is undone here and never sent: a new file
|
||||
or folder goes away, anything else goes back to what Proton Drive has. Export first if you want
|
||||
to keep the content.
|
||||
|
||||
Two cases are not refused, and the activity log records both:
|
||||
|
||||
- A new file or folder whose name is already taken on Proton Drive by something this computer had
|
||||
not seen yet lands under a `(sync-conflict …)` name, as described below.
|
||||
- A new file whose folder was deleted on another device meanwhile lands at the top of My files,
|
||||
so its content is not lost.
|
||||
|
||||
Before you shut down a computer for good, check that nothing is waiting: see
|
||||
[RECOVERY.md](RECOVERY.md#lost-with-the-machine).
|
||||
|
||||
## Conflicts
|
||||
|
||||
When the same file changed on this computer and somewhere else since the last sync, neither side
|
||||
@@ -166,11 +204,6 @@ The version you do not keep goes to the Trash, so a wrong choice can be undone.
|
||||
reading files keeps working. `pdfs sync resume` continues.
|
||||
- **Limit bandwidth** under **Preferences → General → Network**, or with
|
||||
`pdfs sync limit --up 2M --down 10M`. `0` removes a limit.
|
||||
- **Retry** a failed upload from **Sync → Overview**, or with `pdfs sync retry`.
|
||||
- **Discard** a change Proton Drive refused from the same list, or with `pdfs sync discard`: it is
|
||||
undone here and never sent. Export a file first to keep your version.
|
||||
- A file or folder with a change Proton Drive refused carries a warning badge in **Files**; hover
|
||||
it to see why.
|
||||
|
||||
## Photos
|
||||
|
||||
@@ -250,6 +283,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
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 363 KiB |
@@ -0,0 +1,156 @@
|
||||
<svg width="1920" height="1080" viewBox="0 0 1920 1080" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1920" y2="1080" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#1A1240"/>
|
||||
<stop offset="1" stop-color="#0A0618"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow1" cx="1390" cy="520" r="640" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#6D4AFF" stop-opacity="0.55"/>
|
||||
<stop offset="0.5" stop-color="#6D4AFF" stop-opacity="0.15"/>
|
||||
<stop offset="1" stop-color="#6D4AFF" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="glow2" cx="160" cy="1080" r="700" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#A855F7" stop-opacity="0.25"/>
|
||||
<stop offset="1" stop-color="#A855F7" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<pattern id="dots" width="32" height="32" patternUnits="userSpaceOnUse">
|
||||
<circle cx="2" cy="2" r="1.6" fill="#FFFFFF"/>
|
||||
</pattern>
|
||||
<radialGradient id="dotfade" cx="1390" cy="520" r="900" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#FFFFFF" stop-opacity="1"/>
|
||||
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<mask id="dotmask" maskUnits="userSpaceOnUse" x="0" y="0" width="1920" height="1080">
|
||||
<rect width="1920" height="1080" fill="url(#dotfade)"/>
|
||||
</mask>
|
||||
<linearGradient id="title2" x1="140" y1="0" x2="760" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#C9BBFF"/>
|
||||
<stop offset="1" stop-color="#8A6CFF"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- app logo -->
|
||||
<linearGradient id="pdl-face" x1="12" y1="12" x2="116" y2="112" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#A48BFF"/>
|
||||
<stop offset="0.55" stop-color="#7552FF"/>
|
||||
<stop offset="1" stop-color="#5B36F2"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="pdl-cloud" x1="0" y1="34" x2="0" y2="88" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#FFFFFF"/>
|
||||
<stop offset="1" stop-color="#ECE6FF"/>
|
||||
</linearGradient>
|
||||
<mask id="pdl-cut" maskUnits="userSpaceOnUse" x="0" y="0" width="128" height="128">
|
||||
<g fill="#FFFFFF">
|
||||
<circle cx="66" cy="57" r="23"/>
|
||||
<circle cx="42" cy="68" r="16"/>
|
||||
<circle cx="88" cy="69" r="15"/>
|
||||
<rect x="26" y="68" width="77" height="20" rx="10"/>
|
||||
</g>
|
||||
<g stroke="#000000" stroke-width="7.5" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<path d="M64 49v26"/>
|
||||
<path d="M54.5 65.5l9.5 9.5 9.5-9.5"/>
|
||||
</g>
|
||||
</mask>
|
||||
<filter id="logoshadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="6"/>
|
||||
</filter>
|
||||
<filter id="chipshadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="14"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- background -->
|
||||
<rect width="1920" height="1080" fill="url(#bg)"/>
|
||||
<rect width="1920" height="1080" fill="url(#glow2)"/>
|
||||
<rect width="1920" height="1080" fill="url(#glow1)"/>
|
||||
<rect width="1920" height="1080" fill="url(#dots)" opacity="0.07" mask="url(#dotmask)"/>
|
||||
|
||||
<!-- orbit rings -->
|
||||
<g fill="none" stroke="#FFFFFF">
|
||||
<circle cx="1390" cy="520" r="300" stroke-opacity="0.10" stroke-width="2"/>
|
||||
<circle cx="1390" cy="520" r="420" stroke-opacity="0.06" stroke-width="2" stroke-dasharray="4 14" stroke-linecap="round"/>
|
||||
</g>
|
||||
|
||||
<!-- logo, 128 -> 384 -->
|
||||
<g transform="translate(1198 328) scale(3)">
|
||||
<rect x="16" y="24" width="96" height="92" rx="24" fill="#2A1080" opacity="0.8" filter="url(#logoshadow)"/>
|
||||
<rect x="12" y="16" width="104" height="100" rx="26" fill="#4424C0"/>
|
||||
<rect x="12" y="12" width="104" height="100" rx="26" fill="url(#pdl-face)"/>
|
||||
<rect x="12.5" y="12.5" width="103" height="99" rx="25.5" fill="none" stroke="#FFFFFF" stroke-opacity="0.2"/>
|
||||
<rect y="3" width="128" height="128" fill="#2E1596" fill-opacity="0.3" mask="url(#pdl-cut)"/>
|
||||
<rect width="128" height="128" fill="url(#pdl-cloud)" mask="url(#pdl-cut)"/>
|
||||
</g>
|
||||
|
||||
<!-- feature chips around the logo -->
|
||||
<g font-family="Adwaita Sans" font-size="26" font-weight="600" fill="#FFFFFF">
|
||||
<!-- folder, top left -->
|
||||
<g transform="translate(1000 200)">
|
||||
<rect x="0" y="10" width="236" height="76" rx="22" fill="#000000" opacity="0.35" filter="url(#chipshadow)"/>
|
||||
<rect x="0" y="0" width="236" height="76" rx="22" fill="#2A2058" fill-opacity="0.85" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="1.5"/>
|
||||
<g transform="translate(22 20)">
|
||||
<rect width="36" height="36" rx="10" fill="#6D4AFF"/>
|
||||
<path d="M8 13a2 2 0 0 1 2-2h5l2.5 2.5H26a2 2 0 0 1 2 2V25a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2z" fill="#FFFFFF"/>
|
||||
</g>
|
||||
<text x="74" y="47">On demand</text>
|
||||
</g>
|
||||
<!-- sync, top right -->
|
||||
<g transform="translate(1590 290)">
|
||||
<rect x="0" y="10" width="164" height="76" rx="22" fill="#000000" opacity="0.35" filter="url(#chipshadow)"/>
|
||||
<rect x="0" y="0" width="164" height="76" rx="22" fill="#2A2058" fill-opacity="0.85" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="1.5"/>
|
||||
<g transform="translate(22 20)">
|
||||
<rect width="36" height="36" rx="10" fill="#1EA885"/>
|
||||
<g fill="none" stroke="#FFFFFF" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M26 15a8.5 8.5 0 0 0-15-2"/>
|
||||
<path d="M10 9v4.5h4.5"/>
|
||||
<path d="M10 21a8.5 8.5 0 0 0 15 2"/>
|
||||
<path d="M26 27v-4.5h-4.5"/>
|
||||
</g>
|
||||
</g>
|
||||
<text x="74" y="47">Sync</text>
|
||||
</g>
|
||||
<!-- photos, bottom right -->
|
||||
<g transform="translate(1560 690)">
|
||||
<rect x="0" y="10" width="206" height="76" rx="22" fill="#000000" opacity="0.35" filter="url(#chipshadow)"/>
|
||||
<rect x="0" y="0" width="206" height="76" rx="22" fill="#2A2058" fill-opacity="0.85" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="1.5"/>
|
||||
<g transform="translate(22 20)">
|
||||
<rect width="36" height="36" rx="10" fill="#E8618C"/>
|
||||
<rect x="8" y="9" width="20" height="18" rx="3" fill="none" stroke="#FFFFFF" stroke-width="2.4"/>
|
||||
<circle cx="14" cy="15" r="2" fill="#FFFFFF"/>
|
||||
<path d="M9.5 25l6-6 4 4 3-3 4 4" fill="none" stroke="#FFFFFF" stroke-width="2.4" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<text x="74" y="47">Photos</text>
|
||||
</g>
|
||||
<!-- terminal, bottom left -->
|
||||
<g transform="translate(980 790)">
|
||||
<rect x="0" y="10" width="206" height="76" rx="22" fill="#000000" opacity="0.35" filter="url(#chipshadow)"/>
|
||||
<rect x="0" y="0" width="206" height="76" rx="22" fill="#2A2058" fill-opacity="0.85" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="1.5"/>
|
||||
<g transform="translate(22 20)">
|
||||
<rect width="36" height="36" rx="10" fill="#3B3F5C"/>
|
||||
<path d="M9 12l6 6-6 6" fill="none" stroke="#FFFFFF" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M18 25h9" stroke="#FFFFFF" stroke-width="2.6" stroke-linecap="round"/>
|
||||
</g>
|
||||
<text x="74" y="48" font-family="JetBrainsMono NF" font-size="25" font-weight="700">pdfs</text>
|
||||
<text x="150" y="48" font-weight="500" fill="#B9B0D9">CLI</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- text block -->
|
||||
<g font-family="Adwaita Sans">
|
||||
<g transform="translate(140 292)">
|
||||
<rect width="410" height="48" rx="24" fill="#6D4AFF" fill-opacity="0.18" stroke="#8A6CFF" stroke-opacity="0.6" stroke-width="1.5"/>
|
||||
<circle cx="28" cy="24" r="6" fill="#A48BFF"/>
|
||||
<text x="46" y="32" font-size="21" font-weight="700" letter-spacing="2.4" fill="#D6CCFF">UNOFFICIAL · OPEN SOURCE</text>
|
||||
</g>
|
||||
<text x="134" y="478" font-size="124" font-weight="800" letter-spacing="-3" fill="#FFFFFF">Proton Drive</text>
|
||||
<text x="134" y="610" font-size="124" font-weight="800" letter-spacing="-3" fill="url(#title2)">for Linux</text>
|
||||
<text x="140" y="702" font-size="38" font-weight="400" fill="#B9B0D9">Your Drive as a folder, and a native GTK4 app.</text>
|
||||
<text x="140" y="752" font-size="38" font-weight="400" fill="#B9B0D9">No Electron.</text>
|
||||
</g>
|
||||
|
||||
<!-- footer -->
|
||||
<g font-family="JetBrainsMono NF" font-size="24" fill="#8C83B0">
|
||||
<text x="140" y="968">github.com/narrrl/proton-drive-linux</text>
|
||||
</g>
|
||||
<g font-family="Adwaita Sans" font-size="22" fill="#6F6894">
|
||||
<text x="1780" y="968" text-anchor="end">Not affiliated with Proton AG</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.3 KiB |
+1
-1
@@ -9,7 +9,7 @@
|
||||
# Build + install: cd packaging && makepkg -fi
|
||||
|
||||
pkgname=proton-drive-linux
|
||||
pkgver=3.0.0
|
||||
pkgver=3.2.0
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = proton-drive-for-linux-bin
|
||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
||||
pkgver = 2.10.0
|
||||
pkgver = 3.2.0
|
||||
pkgrel = 1
|
||||
url = https://proton-drive.narl.io
|
||||
arch = x86_64
|
||||
@@ -21,21 +21,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=2.10.0
|
||||
provides = proton-drive-linux=2.10.0
|
||||
provides = proton-drive-for-linux=3.2.0
|
||||
provides = proton-drive-linux=3.2.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-2.10.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v2.10.0/proton-drive-linux-2.10.0-x86_64.tar.gz
|
||||
source = io.narl.proton-drive-linux-2.10.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.10.0/packaging/io.narl.proton-drive-linux.desktop
|
||||
source = io.narl.proton-drive-linux-tray-2.10.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.10.0/packaging/io.narl.proton-drive-linux-tray.desktop
|
||||
source = io.narl.proton-drive-linux-2.10.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.10.0/packaging/io.narl.proton-drive-linux.svg
|
||||
source = proton-drive-2.10.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.10.0/packaging/proton-drive.service
|
||||
source = LICENSE-2.10.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.10.0/LICENSE
|
||||
sha256sums = b653dbd7d5939c504c55a91e14f1cd3e38aabc94d8bbd6344d2f79b3cd670313
|
||||
source = proton-drive-linux-3.2.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.2.0/proton-drive-linux-3.2.0-x86_64.tar.gz
|
||||
source = io.narl.proton-drive-linux-3.2.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.2.0/packaging/io.narl.proton-drive-linux.desktop
|
||||
source = io.narl.proton-drive-linux-tray-3.2.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.2.0/packaging/io.narl.proton-drive-linux-tray.desktop
|
||||
source = io.narl.proton-drive-linux-3.2.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.2.0/packaging/io.narl.proton-drive-linux.svg
|
||||
source = proton-drive-3.2.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.2.0/packaging/proton-drive.service
|
||||
source = LICENSE-3.2.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.2.0/LICENSE
|
||||
sha256sums = 3d498c9ea366c94e77e6f3c81b804b8b3497faecfa74480274d6ec9b5121665b
|
||||
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
||||
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
||||
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||
|
||||
pkgname=proton-drive-for-linux-bin
|
||||
pkgver=2.10.0
|
||||
pkgver=3.2.0
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
||||
arch=('x86_64')
|
||||
@@ -34,7 +34,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=('b653dbd7d5939c504c55a91e14f1cd3e38aabc94d8bbd6344d2f79b3cd670313'
|
||||
sha256sums=('3d498c9ea366c94e77e6f3c81b804b8b3497faecfa74480274d6ec9b5121665b'
|
||||
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
||||
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
||||
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = proton-drive-for-linux-git
|
||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
|
||||
pkgver = 2.10.0.r6.ga6f36d5
|
||||
pkgver = 3.2.0.r0.g4c498d5
|
||||
pkgrel = 1
|
||||
url = https://proton-drive.narl.io
|
||||
arch = x86_64
|
||||
@@ -24,8 +24,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=2.10.0.r6.ga6f36d5
|
||||
provides = proton-drive-linux=2.10.0.r6.ga6f36d5
|
||||
provides = proton-drive-for-linux=3.2.0.r0.g4c498d5
|
||||
provides = proton-drive-linux=3.2.0.r0.g4c498d5
|
||||
provides = pdfs
|
||||
conflicts = proton-drive-linux
|
||||
conflicts = proton-drive-for-linux
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||
|
||||
pkgname=proton-drive-for-linux-git
|
||||
pkgver=2.10.0.r6.ga6f36d5
|
||||
pkgver=3.2.0.r0.g4c498d5
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = proton-drive-for-linux
|
||||
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
||||
pkgver = 2.10.0
|
||||
pkgver = 3.2.0
|
||||
pkgrel = 1
|
||||
url = https://proton-drive.narl.io
|
||||
arch = x86_64
|
||||
@@ -23,13 +23,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=2.10.0
|
||||
provides = proton-drive-linux=3.2.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-2.10.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v2.10.0.tar.gz
|
||||
sha256sums = 7aa5fcb76dca13262bd678c6f280795ab331a20d5549c4aa7d2c3d4eec6fcd8e
|
||||
source = proton-drive-for-linux-3.2.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.2.0.tar.gz
|
||||
sha256sums = a15ddb37808dc01bde3a3d092b03b5047cdecca37efd14d906c9bf353d242bd8
|
||||
|
||||
pkgname = proton-drive-for-linux
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||
|
||||
pkgname=proton-drive-for-linux
|
||||
pkgver=2.10.0
|
||||
pkgver=3.2.0
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
||||
arch=('x86_64')
|
||||
@@ -24,7 +24,7 @@ conflicts=('proton-drive-linux' 'proton-drive-for-linux-bin' 'proton-drive-for-l
|
||||
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
|
||||
options=('!lto')
|
||||
source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz")
|
||||
sha256sums=('7aa5fcb76dca13262bd678c6f280795ab331a20d5549c4aa7d2c3d4eec6fcd8e')
|
||||
sha256sums=('a15ddb37808dc01bde3a3d092b03b5047cdecca37efd14d906c9bf353d242bd8')
|
||||
|
||||
_srcname="proton-drive-linux-$pkgver"
|
||||
|
||||
|
||||
+794
-621
File diff suppressed because it is too large.
Load diff
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user