mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18cc2ea498 | ||
|
|
a9cb389f04 | ||
|
|
923c47acb8 | ||
|
|
478a0e6cdd | ||
|
|
2633b4ed72 | ||
|
|
b46abfc07c | ||
|
|
fd78f088ec | ||
|
|
005f7ded1f | ||
|
|
3e4886f965 | ||
|
|
aac53e3139 | ||
|
|
c2bf87596f | ||
|
|
69d4feaa07 | ||
|
|
d481dcc15c | ||
|
|
e0442c56b9 | ||
|
|
ee76f4ac60 | ||
|
|
f6c651b06b | ||
|
|
8b7d7ae87f | ||
|
|
160626b29d | ||
|
|
f1f1f8ad4d | ||
|
|
bb28a18f49 | ||
|
|
a3c0c9ee9b | ||
|
|
91fa1614ed | ||
|
|
87361a4573 | ||
|
|
6d0b6ad553 | ||
|
|
170c3f01ff | ||
|
|
976ad2aa04 | ||
|
|
b73cd087aa |
No files matched your search
@@ -38,6 +38,13 @@ jobs:
|
|||||||
|
|
||||||
- uses: cachix/install-nix-action@v31
|
- uses: cachix/install-nix-action@v31
|
||||||
|
|
||||||
|
# Pulls from the project's binary cache, and pushes what this run built
|
||||||
|
# when the token is there; a fork's pull request has none and only pulls.
|
||||||
|
- uses: cachix/cachix-action@v17
|
||||||
|
with:
|
||||||
|
name: proton-drive-linux
|
||||||
|
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||||
|
|
||||||
- name: Check the flake
|
- name: Check the flake
|
||||||
run: nix flake check --print-build-logs --no-build
|
run: nix flake check --print-build-logs --no-build
|
||||||
|
|
||||||
|
|||||||
@@ -153,6 +153,9 @@ jobs:
|
|||||||
cp LICENSE deb/usr/share/licenses/proton-drive-linux/
|
cp LICENSE deb/usr/share/licenses/proton-drive-linux/
|
||||||
po/build.sh deb/usr/share/locale
|
po/build.sh deb/usr/share/locale
|
||||||
|
|
||||||
|
# Stops the running daemon before removal, so the mount stops mirroring deletes
|
||||||
|
install -m755 packaging/deb/prerm deb/DEBIAN/prerm
|
||||||
|
|
||||||
# Calculate Installed-Size in KB for the control file
|
# Calculate Installed-Size in KB for the control file
|
||||||
INSTALLED_SIZE=$(du -sk deb | cut -f1)
|
INSTALLED_SIZE=$(du -sk deb | cut -f1)
|
||||||
|
|
||||||
@@ -230,21 +233,67 @@ jobs:
|
|||||||
*.rpm
|
*.rpm
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
|
# The flake is the Nix release: a tag is what `github:narrrl/proton-drive-linux/vX.Y.Z`
|
||||||
|
# pins, so it has to build before the release is announced.
|
||||||
|
nix:
|
||||||
|
name: Build the Nix package
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v31
|
||||||
|
|
||||||
|
# Pulls from the project's binary cache, and pushes what this run built
|
||||||
|
# when the token is there; a fork's pull request has none and only pulls.
|
||||||
|
- uses: cachix/cachix-action@v17
|
||||||
|
with:
|
||||||
|
name: proton-drive-linux
|
||||||
|
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build the flake
|
||||||
|
run: nix build --print-build-logs .#proton-drive-linux
|
||||||
|
|
||||||
|
- name: Check the version
|
||||||
|
run: |
|
||||||
|
built="$(./result/bin/pdfs --version)"
|
||||||
|
echo "$built"
|
||||||
|
case "$GITHUB_REF" in
|
||||||
|
refs/tags/v*) grep -qF "${GITHUB_REF_NAME#v}" <<< "$built" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
name: Create GitHub Release
|
name: Create GitHub Release
|
||||||
needs: [checks, build-release]
|
needs: [checks, build-release, nix]
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Download packages
|
- name: Download packages
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: proton-drive-linux-builds
|
name: proton-drive-linux-builds
|
||||||
|
|
||||||
|
# The release notes are this version's section of the changelog, from its
|
||||||
|
# heading up to the next one.
|
||||||
|
- name: Extract release notes
|
||||||
|
run: |
|
||||||
|
version="${GITHUB_REF_NAME#v}"
|
||||||
|
awk -v heading="## [$version]" \
|
||||||
|
'index($0, heading) == 1 { found = 1; print; next }
|
||||||
|
found && /^## \[/ { exit }
|
||||||
|
found' docs/CHANGELOG.md > release-notes.md
|
||||||
|
if [ ! -s release-notes.md ]; then
|
||||||
|
echo "::error::docs/CHANGELOG.md has no section for $version"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v3
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
|
body_path: release-notes.md
|
||||||
files: |
|
files: |
|
||||||
*.deb
|
*.deb
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
|||||||
Generated
+8
-8
@@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "3.2.0"
|
version = "3.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -3490,7 +3490,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "3.2.0"
|
version = "3.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3516,7 +3516,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "3.2.0"
|
version = "3.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3538,7 +3538,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "3.2.0"
|
version = "3.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3778,9 +3778,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.8.2"
|
version = "0.9.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e789cab811c5e73e47fd97810b78a513cb254f4dd763b4eb3cad680d6dd31da"
|
checksum = "9e6b87f78711e8fc72737c8eb24ebda156079308d79ab37ca2b1b32de6812880"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3805,9 +3805,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.8.2"
|
version = "0.9.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ace260acbf88c26f5bd4aef8c8c9898c322163c56620bdef3b6dd7742bec7619"
|
checksum = "19f1936755c603b98de1c82a5b343c6232e9fea8adaa77139f7cb8fcddcda697"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.1",
|
"aes-gcm 0.11.1",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+3
-3
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "3.2.0"
|
version = "3.4.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
|||||||
categories = ["filesystem", "command-line-utilities"]
|
categories = ["filesystem", "command-line-utilities"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.8.2" }
|
proton-sdk = { version = "0.9.1" }
|
||||||
proton-drive-rs = { version = "0.8.2" }
|
proton-drive-rs = { version = "0.9.1" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ video thumbnails), headless use and uninstalling.
|
|||||||
| [Configuration](docs/CONFIGURATION.md) | `config.json`, file locations, environment variables, the service |
|
| [Configuration](docs/CONFIGURATION.md) | `config.json`, file locations, environment variables, the service |
|
||||||
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Diagnosing problems and fixing common ones |
|
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Diagnosing problems and fixing common ones |
|
||||||
| [Recovering on a new computer](docs/RECOVERY.md) | Getting your synced folders back after a reinstall or a lost machine |
|
| [Recovering on a new computer](docs/RECOVERY.md) | Getting your synced folders back after a reinstall or a lost machine |
|
||||||
|
| [Security model](docs/SECURITY_MODEL.md) | What protects your files, what stays on this computer, what the client connects to |
|
||||||
| [Changelog](docs/CHANGELOG.md) | What changed in each release |
|
| [Changelog](docs/CHANGELOG.md) | What changed in each release |
|
||||||
|
|
||||||
For contributors: [development guide](docs/DEVELOPMENT.md), [architecture](docs/ARCHITECTURE.md),
|
For contributors: [development guide](docs/DEVELOPMENT.md), [architecture](docs/ARCHITECTURE.md),
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
|
|
||||||
Fixes land on the latest release. Please reproduce on the most recent version before reporting.
|
Fixes land on the latest release. Please reproduce on the most recent version before reporting.
|
||||||
|
|
||||||
|
For what the client protects and what it does not, written for users, see the
|
||||||
|
[security model](docs/SECURITY_MODEL.md).
|
||||||
|
|
||||||
## Reporting a vulnerability
|
## Reporting a vulnerability
|
||||||
|
|
||||||
**Do not open a public issue.** Report privately through GitHub's
|
**Do not open a public issue.** Report privately through GitHub's
|
||||||
|
|||||||
+70
-13
@@ -15,7 +15,7 @@ use pdfs_core::config::AppDirs;
|
|||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ConflictKeep, ErrorKind, MountKind, RefreshScope, Request as CtlRequest,
|
ConflictKeep, ErrorKind, MountKind, RefreshScope, Request as CtlRequest,
|
||||||
Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncIssue, SyncPhase,
|
Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncIssue, SyncPhase,
|
||||||
pending_summary,
|
daemon_outdated, pending_summary,
|
||||||
};
|
};
|
||||||
use pdfs_core::db::Db;
|
use pdfs_core::db::Db;
|
||||||
use pdfs_core::service;
|
use pdfs_core::service;
|
||||||
@@ -172,17 +172,19 @@ enum Command {
|
|||||||
/// Mountpoint; defaults to ~/ProtonDrive.
|
/// Mountpoint; defaults to ~/ProtonDrive.
|
||||||
mountpoint: Option<PathBuf>,
|
mountpoint: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
/// Keep a file on this device (download + cache it). Needs a running mount.
|
/// Keep a file or folder on this device (download + cache it). A folder's
|
||||||
|
/// files download in the background; `pdfs transfers` shows the progress.
|
||||||
|
/// Needs a running mount.
|
||||||
Pin {
|
Pin {
|
||||||
/// File path, inside the mountpoint or relative to it.
|
/// File or folder path, inside the mountpoint or relative to it.
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
},
|
},
|
||||||
/// Stop keeping a file on this device, evicting its cached content.
|
/// Stop keeping a file or folder on this device, evicting its cached content.
|
||||||
Unpin {
|
Unpin {
|
||||||
/// File path, inside the mountpoint or relative to it.
|
/// File or folder path, inside the mountpoint or relative to it.
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
},
|
},
|
||||||
/// List files pinned to this device.
|
/// List files and folders pinned to this device.
|
||||||
Pins,
|
Pins,
|
||||||
/// List a directory via the running daemon (in-app browser backend).
|
/// List a directory via the running daemon (in-app browser backend).
|
||||||
Ls {
|
Ls {
|
||||||
@@ -1865,7 +1867,22 @@ fn cmd_login(username: Option<String>, remember: bool) -> Result<()> {
|
|||||||
get_mailbox,
|
get_mailbox,
|
||||||
get_hv,
|
get_hv,
|
||||||
))
|
))
|
||||||
.context("login failed")?;
|
.map_err(|e| {
|
||||||
|
// Proton words this block as "update the app", which the user can't
|
||||||
|
// act on: the release is current and only waiting helps.
|
||||||
|
let blocked =
|
||||||
|
matches!(&e, pdfs_core::Error::Proton(p) if pdfs_core::error::sign_in_blocked(p));
|
||||||
|
let e = anyhow::Error::new(e).context("login failed");
|
||||||
|
if blocked {
|
||||||
|
e.context(
|
||||||
|
"Proton is temporarily refusing sign-in from this device (code 2028). \
|
||||||
|
Your version is not the problem. Wait several hours, up to a day, \
|
||||||
|
without trying again, then sign in.",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
e
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
// A daemon that is already up is parked in `wait_for_session`, sleeping
|
// 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
|
// between keyring checks; restart it so the mount appears now instead of
|
||||||
@@ -2026,6 +2043,7 @@ fn cmd_status() -> Result<()> {
|
|||||||
paused_until,
|
paused_until,
|
||||||
session_expired,
|
session_expired,
|
||||||
tokens_unsaved,
|
tokens_unsaved,
|
||||||
|
version,
|
||||||
..
|
..
|
||||||
}) => {
|
}) => {
|
||||||
let state = match (online, paused) {
|
let state = match (online, paused) {
|
||||||
@@ -2067,6 +2085,9 @@ fn cmd_status() -> Result<()> {
|
|||||||
"Keyring refuses the refreshed sign-in; unlock it, or a reboot needs `pdfs login`"
|
"Keyring refuses the refreshed sign-in; unlock it, or a reboot needs `pdfs login`"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if daemon_outdated(&version) {
|
||||||
|
println!("Daemon {}", outdated_daemon_note(&version));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(other) => println!("Mount: unexpected response {other:?}"),
|
Ok(other) => println!("Mount: unexpected response {other:?}"),
|
||||||
Err(_) => println!("Mount: not running."),
|
Err(_) => println!("Mount: not running."),
|
||||||
@@ -2074,6 +2095,20 @@ fn cmd_status() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What `pdfs status` and `pdfs diagnose` say about a daemon still running
|
||||||
|
/// an older release than this CLI.
|
||||||
|
fn outdated_daemon_note(version: &str) -> String {
|
||||||
|
let running = if version.is_empty() {
|
||||||
|
"an older release".to_string()
|
||||||
|
} else {
|
||||||
|
version.to_string()
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"still runs {running}, this is {}; run `systemctl --user restart proton-drive` to finish the update",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn cmd_locations() -> Result<()> {
|
fn cmd_locations() -> Result<()> {
|
||||||
let response = control_request(CtlRequest::ListLocations)?;
|
let response = control_request(CtlRequest::ListLocations)?;
|
||||||
if emit_json(&response)? {
|
if emit_json(&response)? {
|
||||||
@@ -3376,12 +3411,25 @@ fn cmd_diagnose() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let db_path = dirs.db_path();
|
let db_path = dirs.db_path();
|
||||||
let db_size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
|
if db_path.exists() {
|
||||||
report.check(
|
let db_size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
|
||||||
" database",
|
report.check(
|
||||||
db_path.is_file(),
|
" database",
|
||||||
format!("{} ({})", db_path.display(), human_bytes(db_size)),
|
db_path.is_file(),
|
||||||
);
|
format!("{} ({})", db_path.display(), human_bytes(db_size)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// The daemon creates it on its first start, which waits for a sign-in,
|
||||||
|
// so a fresh install has none yet (issue #39 read this as the fault).
|
||||||
|
report.finding(
|
||||||
|
DiagnoseLevel::Warn,
|
||||||
|
" database",
|
||||||
|
format!(
|
||||||
|
"{} not created yet (the daemon creates it on first start)",
|
||||||
|
db_path.display()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
println!("\nFUSE");
|
println!("\nFUSE");
|
||||||
let fuse = Path::new("/dev/fuse");
|
let fuse = Path::new("/dev/fuse");
|
||||||
@@ -3444,9 +3492,17 @@ fn cmd_diagnose() -> Result<()> {
|
|||||||
tokens_unsaved,
|
tokens_unsaved,
|
||||||
pending_uploads,
|
pending_uploads,
|
||||||
pending_changes,
|
pending_changes,
|
||||||
|
version,
|
||||||
..
|
..
|
||||||
}) => {
|
}) => {
|
||||||
report.finding(DiagnoseLevel::Ok, " daemon responding", "");
|
report.finding(DiagnoseLevel::Ok, " daemon responding", "");
|
||||||
|
if daemon_outdated(&version) {
|
||||||
|
report.finding(
|
||||||
|
DiagnoseLevel::Warn,
|
||||||
|
" daemon version",
|
||||||
|
outdated_daemon_note(&version),
|
||||||
|
);
|
||||||
|
}
|
||||||
let live_type = mounted_fs_type(Path::new(&mountpoint));
|
let live_type = mounted_fs_type(Path::new(&mountpoint));
|
||||||
let is_fuse = live_type
|
let is_fuse = live_type
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -3767,6 +3823,7 @@ mod login_wait_tests {
|
|||||||
size: 1,
|
size: 1,
|
||||||
modified: 0,
|
modified: 0,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
|
pinned_by_folder: false,
|
||||||
cached: false,
|
cached: false,
|
||||||
uid: "vol~link".into(),
|
uid: "vol~link".into(),
|
||||||
path: String::new(),
|
path: String::new(),
|
||||||
|
|||||||
@@ -1260,8 +1260,15 @@ pub struct DirEntry {
|
|||||||
pub size: u64,
|
pub size: u64,
|
||||||
/// Modification time, epoch seconds.
|
/// Modification time, epoch seconds.
|
||||||
pub modified: i64,
|
pub modified: i64,
|
||||||
/// Whether the file is pinned to this device.
|
/// Whether the node is pinned to this device, on its own or through a
|
||||||
|
/// pinned folder above it.
|
||||||
pub pinned: bool,
|
pub pinned: bool,
|
||||||
|
/// Whether the node is kept on this device because a folder above it is
|
||||||
|
/// pinned. Such a node cannot be unpinned on its own. Set in
|
||||||
|
/// [`Request::ListDir`] listings only. Defaulted for wire-compat with
|
||||||
|
/// daemons predating the field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub pinned_by_folder: bool,
|
||||||
/// Whether the file's full content is present in the local cache (a current,
|
/// Whether the file's full content is present in the local cache (a current,
|
||||||
/// non-stale blob). Always false for folders. Defaulted for wire-compat with
|
/// non-stale blob). Always false for folders. Defaulted for wire-compat with
|
||||||
/// clients/daemons predating the field.
|
/// clients/daemons predating the field.
|
||||||
@@ -1850,6 +1857,27 @@ pub fn pending_summary(uploads: u64, changes: u64) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the daemon that reported `version` in a [`Response::Status`] is an
|
||||||
|
/// older release than this build. Installing a package replaces the binaries
|
||||||
|
/// but leaves the running user service on the old one, which keeps its bugs
|
||||||
|
/// until it restarts, while the app and the CLI already describe the new
|
||||||
|
/// release (#33). Empty means a daemon from before the field, so older too.
|
||||||
|
///
|
||||||
|
/// Only older counts. A newer daemon means this front-end is the one still
|
||||||
|
/// running from before the upgrade, and restarting the service would not help.
|
||||||
|
pub fn daemon_outdated(version: &str) -> bool {
|
||||||
|
fn parse(v: &str) -> Option<(u64, u64, u64)> {
|
||||||
|
let mut parts = v.split('.').map(|p| p.parse().ok());
|
||||||
|
let parsed = (parts.next()??, parts.next()??, parts.next()??);
|
||||||
|
parts.next().is_none().then_some(parsed)
|
||||||
|
}
|
||||||
|
match (parse(version), parse(env!("CARGO_PKG_VERSION"))) {
|
||||||
|
(Some(running), Some(this)) => running < this,
|
||||||
|
(None, _) => version.is_empty(),
|
||||||
|
(Some(_), None) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One worker thread in a [`Diagnostics`] report.
|
/// One worker thread in a [`Diagnostics`] report.
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct WorkerState {
|
pub struct WorkerState {
|
||||||
@@ -1969,6 +1997,11 @@ pub enum Response {
|
|||||||
/// out.
|
/// out.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
tokens_unsaved: bool,
|
tokens_unsaved: bool,
|
||||||
|
/// The release of the daemon that answered. A package upgrade does not
|
||||||
|
/// restart the user service, so it can be older than the front-end
|
||||||
|
/// asking; daemons before 3.3.0 omit it, which reads as empty.
|
||||||
|
#[serde(default)]
|
||||||
|
version: String,
|
||||||
},
|
},
|
||||||
/// A human-readable success message.
|
/// A human-readable success message.
|
||||||
Ok { message: String },
|
Ok { message: String },
|
||||||
@@ -2612,6 +2645,27 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #33: a daemon left running over an upgrade is named as older, also when
|
||||||
|
/// it predates the version field, but a newer one is not, since restarting
|
||||||
|
/// the service cannot bring it closer to this build.
|
||||||
|
#[test]
|
||||||
|
fn only_an_older_daemon_is_outdated() {
|
||||||
|
assert!(!daemon_outdated(env!("CARGO_PKG_VERSION")));
|
||||||
|
assert!(daemon_outdated(""));
|
||||||
|
assert!(daemon_outdated("0.9.9"));
|
||||||
|
assert!(!daemon_outdated("999.0.0"));
|
||||||
|
assert!(!daemon_outdated("not-a-version"));
|
||||||
|
let old: Response = serde_json::from_str(
|
||||||
|
r#"{"Status":{"username":"u","mountpoint":"/m","pinned":0,
|
||||||
|
"used":0,"budget":0,"pins":[]}}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let Response::Status { version, .. } = old else {
|
||||||
|
panic!("not a status: {old:?}");
|
||||||
|
};
|
||||||
|
assert!(daemon_outdated(&version));
|
||||||
|
}
|
||||||
|
|
||||||
/// The mutation requests must survive a line-delimited JSON round-trip, since
|
/// The mutation requests must survive a line-delimited JSON round-trip, since
|
||||||
/// that is exactly how they cross the control socket.
|
/// that is exactly how they cross the control socket.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1689,9 +1689,16 @@ fn writing_nodes_does_not_scale_with_the_size_of_the_index() {
|
|||||||
.collect();
|
.collect();
|
||||||
db.upsert_nodes(&batch).unwrap();
|
db.upsert_nodes(&batch).unwrap();
|
||||||
|
|
||||||
let t = Instant::now();
|
// The fastest of a few runs: the rest of the suite runs alongside, and
|
||||||
db.upsert_nodes(&batch).unwrap();
|
// one run that lost the CPU to it read as the regression.
|
||||||
t.elapsed()
|
(0..5)
|
||||||
|
.map(|_| {
|
||||||
|
let t = Instant::now();
|
||||||
|
db.upsert_nodes(&batch).unwrap();
|
||||||
|
t.elapsed()
|
||||||
|
})
|
||||||
|
.min()
|
||||||
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
let small = cost_against_index_of(200);
|
let small = cost_against_index_of(200);
|
||||||
|
|||||||
@@ -154,17 +154,17 @@ impl CoreError {
|
|||||||
/// through the boxes the drain deals in, where the concrete type survives
|
/// through the boxes the drain deals in, where the concrete type survives
|
||||||
/// but the static one does not.
|
/// but the static one does not.
|
||||||
///
|
///
|
||||||
/// A failure that is not an API response at all — a transport error, a
|
/// A transport error, a timeout or a TLS failure is what being offline
|
||||||
/// timeout, a TLS failure — is what being offline looks like from here, so
|
/// looks like from here, so it classifies as [`ErrorKind::Offline`] rather
|
||||||
/// it classifies as [`ErrorKind::Offline`] rather than
|
/// than [`ErrorKind::Remote`]: the request never reached Proton, and
|
||||||
/// [`ErrorKind::Remote`]: the request never reached Proton, and telling the
|
/// telling the user their connection is down beats telling them the
|
||||||
/// user their connection is down beats telling them the server refused.
|
/// server refused. The SDK's other failures say what went wrong instead
|
||||||
|
/// (see [`proton_kind`]).
|
||||||
pub fn from_api(e: &(dyn std::error::Error + 'static), what: &str) -> Self {
|
pub fn from_api(e: &(dyn std::error::Error + 'static), what: &str) -> Self {
|
||||||
let kind = match e.downcast_ref::<proton_sdk::ProtonError>() {
|
let kind = match e.downcast_ref::<proton_sdk::ProtonError>() {
|
||||||
Some(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
|
Some(proton) => proton_kind(proton),
|
||||||
// A `ProtonError` that is not an `Api` variant never reached the
|
// Not the SDK's at all: a timeout around the call, or the socket.
|
||||||
// API; so does anything that is not a `ProtonError` at all.
|
None => ErrorKind::Offline,
|
||||||
_ => ErrorKind::Offline,
|
|
||||||
};
|
};
|
||||||
Self::new(kind, format!("{what}: {e}"))
|
Self::new(kind, format!("{what}: {e}"))
|
||||||
}
|
}
|
||||||
@@ -214,15 +214,33 @@ impl From<Error> for CoreError {
|
|||||||
| Error::Locked
|
| Error::Locked
|
||||||
| Error::WrongMailboxPassword
|
| Error::WrongMailboxPassword
|
||||||
| Error::HumanVerificationRequired(_) => ErrorKind::Denied,
|
| Error::HumanVerificationRequired(_) => ErrorKind::Denied,
|
||||||
Error::Proton(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
|
Error::Proton(proton) => proton_kind(proton),
|
||||||
// A `ProtonError` that never reached the API is a transport failure.
|
|
||||||
Error::Proton(_) => ErrorKind::Offline,
|
|
||||||
_ => ErrorKind::Internal,
|
_ => ErrorKind::Internal,
|
||||||
};
|
};
|
||||||
Self::new(kind, e.to_string())
|
Self::new(kind, e.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a failed SDK call means to a front-end.
|
||||||
|
///
|
||||||
|
/// Only a transport failure is the connection. The rest happened on this
|
||||||
|
/// machine, and calling them "offline" sent the user to check a network that
|
||||||
|
/// worked (issue #38: a photo upload on an account without a photo library
|
||||||
|
/// read "You're offline").
|
||||||
|
fn proton_kind(e: &proton_sdk::ProtonError) -> ErrorKind {
|
||||||
|
use proton_sdk::ProtonError as P;
|
||||||
|
match e {
|
||||||
|
P::Api(api) => api_kind(api.code),
|
||||||
|
P::Transport(_) => ErrorKind::Offline,
|
||||||
|
// Proton answered, but not with anything this client can read.
|
||||||
|
P::Serialization(_) => ErrorKind::Remote,
|
||||||
|
// The SDK refused the call before sending it, such as an upload to a
|
||||||
|
// photo library the account does not have.
|
||||||
|
P::InvalidOperation(_) => ErrorKind::Invalid,
|
||||||
|
P::Crypto(_) | P::KeysLocked => ErrorKind::Internal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What an API response code means to a front-end.
|
/// What an API response code means to a front-end.
|
||||||
///
|
///
|
||||||
/// The grouping is by *what the user can do about it*, which is what
|
/// The grouping is by *what the user can do about it*, which is what
|
||||||
@@ -295,6 +313,15 @@ pub fn session_revoked(e: &proton_sdk::ProtonError) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `e` is Proton's temporary block on a sign-in (code 2028).
|
||||||
|
///
|
||||||
|
/// Its message says the app version is no longer supported, which is not what
|
||||||
|
/// happened: the release is current and only waiting helps (issues #28, #39).
|
||||||
|
/// A front-end must say that instead of passing the message through.
|
||||||
|
pub fn sign_in_blocked(e: &proton_sdk::ProtonError) -> bool {
|
||||||
|
matches!(e, proton_sdk::ProtonError::Api(api) if api.code == proton_sdk::api::ResponseCode::Blocked)
|
||||||
|
}
|
||||||
|
|
||||||
/// The result every request-serving method in the daemon returns.
|
/// The result every request-serving method in the daemon returns.
|
||||||
pub type CoreResult<T> = std::result::Result<T, CoreError>;
|
pub type CoreResult<T> = std::result::Result<T, CoreError>;
|
||||||
|
|
||||||
@@ -337,6 +364,14 @@ mod tests {
|
|||||||
assert!(!session_revoked(&ProtonError::KeysLocked));
|
assert!(!session_revoked(&ProtonError::KeysLocked));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_code_2028_is_a_blocked_sign_in() {
|
||||||
|
assert!(sign_in_blocked(&api(C::Blocked)));
|
||||||
|
assert!(!sign_in_blocked(&api(C::OutdatedApp)));
|
||||||
|
assert!(!sign_in_blocked(&api(C::TooManyRequests)));
|
||||||
|
assert!(!sign_in_blocked(&ProtonError::KeysLocked));
|
||||||
|
}
|
||||||
|
|
||||||
/// The regression this whole pass exists to prevent: before it, every SDK
|
/// 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
|
/// failure was hardcoded to `Remote`, so a 403 asked the user to retry a
|
||||||
/// request that could never succeed.
|
/// request that could never succeed.
|
||||||
@@ -367,6 +402,18 @@ mod tests {
|
|||||||
assert_eq!(CoreError::from_api(&io, "list").kind, ErrorKind::Offline);
|
assert_eq!(CoreError::from_api(&io, "list").kind, ErrorKind::Offline);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Issue #38: the SDK refused a photo upload before sending it, and the
|
||||||
|
/// app said "You're offline" while every other call went through.
|
||||||
|
#[test]
|
||||||
|
fn a_call_the_sdk_refuses_to_send_is_not_offline() {
|
||||||
|
let refused = ProtonError::invalid_operation("account has no photos volume");
|
||||||
|
let e = CoreError::from_api(&refused, "upload photo failed");
|
||||||
|
assert_eq!(e.kind, ErrorKind::Invalid);
|
||||||
|
assert!(e.message.ends_with("account has no photos volume"));
|
||||||
|
let e: CoreError = Error::Proton(ProtonError::KeysLocked).into();
|
||||||
|
assert_ne!(e.kind, ErrorKind::Offline);
|
||||||
|
}
|
||||||
|
|
||||||
/// `Error` is the plumbing type; converting it must classify as sharply as
|
/// `Error` is the plumbing type; converting it must classify as sharply as
|
||||||
/// the direct path, or the two disagree about the same failure.
|
/// the direct path, or the two disagree about the same failure.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -336,6 +336,7 @@ pub(crate) fn status_response(core: &Core, username: &str, mountpoint: &Path) ->
|
|||||||
online: core.online.load(Ordering::Relaxed),
|
online: core.online.load(Ordering::Relaxed),
|
||||||
session_expired: core.session_expired(),
|
session_expired: core.session_expired(),
|
||||||
tokens_unsaved: pdfs_core::auth::tokens_unsaved(),
|
tokens_unsaved: pdfs_core::auth::tokens_unsaved(),
|
||||||
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
pending_uploads: queued.uploads.max(0) as u64 + landing,
|
pending_uploads: queued.uploads.max(0) as u64 + landing,
|
||||||
pending_changes: queued.changes.max(0) as u64,
|
pending_changes: queued.changes.max(0) as u64,
|
||||||
}
|
}
|
||||||
@@ -940,6 +941,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
// The reply goes to the app's toast only; without
|
||||||
|
// this the journal shows a failure and no reason.
|
||||||
|
warn!(error = %e, "photo upload failed");
|
||||||
CtlResponse::error(CoreError::from_api(&e, "upload photo failed"))
|
CtlResponse::error(CoreError::from_api(&e, "upload photo failed"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ impl Core {
|
|||||||
pub(crate) fn list_devices(&self) -> CoreResult<Vec<DeviceInfo>> {
|
pub(crate) fn list_devices(&self) -> CoreResult<Vec<DeviceInfo>> {
|
||||||
let devices = self
|
let devices = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_devices())
|
.block_on(self.drive.enumerate_devices())
|
||||||
.map_err(|e| CoreError::from_api(&e, "list devices"))?;
|
.map_err(|e| CoreError::from_api(&e, "list devices"))?;
|
||||||
// No cached device row yet means this machine syncs nothing, so none of
|
// No cached device row yet means this machine syncs nothing, so none of
|
||||||
// the listed devices is ours.
|
// the listed devices is ours.
|
||||||
@@ -214,7 +214,7 @@ impl Core {
|
|||||||
};
|
};
|
||||||
let remote = self
|
let remote = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_devices())
|
.block_on(self.drive.enumerate_devices())
|
||||||
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
||||||
let dev = remote
|
let dev = remote
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -257,7 +257,7 @@ impl Core {
|
|||||||
};
|
};
|
||||||
let remote = self
|
let remote = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_devices())
|
.block_on(self.drive.enumerate_devices())
|
||||||
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
||||||
let d = remote
|
let d = remote
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -395,7 +395,7 @@ impl Core {
|
|||||||
// any cached record, and recover an existing device by name.
|
// any cached record, and recover an existing device by name.
|
||||||
let remote = self
|
let remote = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_devices())
|
.block_on(self.drive.enumerate_devices())
|
||||||
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
||||||
|
|
||||||
// An adopted uid is an explicit instruction, so it outranks everything
|
// An adopted uid is an explicit instruction, so it outranks everything
|
||||||
@@ -454,7 +454,7 @@ impl Core {
|
|||||||
None => {
|
None => {
|
||||||
let d = self
|
let d = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.create_device(&name, DeviceType::Linux))
|
.block_on(self.drive.create_device(&name, DeviceType::Linux))
|
||||||
.map_err(|e| CoreError::from_api(&e, "create device"))?;
|
.map_err(|e| CoreError::from_api(&e, "create device"))?;
|
||||||
StoredDevice {
|
StoredDevice {
|
||||||
uid: d.uid.to_string(),
|
uid: d.uid.to_string(),
|
||||||
|
|||||||
@@ -8,8 +8,10 @@
|
|||||||
//! real client implements it by forwarding, and the simulation tests implement
|
//! real client implements it by forwarding, and the simulation tests implement
|
||||||
//! it with an in-memory Drive that injects Drive's known faults.
|
//! it with an in-memory Drive that injects Drive's known faults.
|
||||||
//!
|
//!
|
||||||
//! Sharing, Photos, devices, invitations and public links stay on the concrete
|
//! Sharing, Photos, invitations, public links and the rest of the device calls
|
||||||
//! client (`Core::client`) until a test needs them faked.
|
//! stay on the concrete client (`Core::client`) until a test needs them faked.
|
||||||
|
//! Listing and registering devices are here, so a simulated daemon can sync a
|
||||||
|
//! local folder.
|
||||||
//!
|
//!
|
||||||
//! The method names match the client's own, so a call site reads the same
|
//! The method names match the client's own, so a call site reads the same
|
||||||
//! whichever it goes through. The client's generic methods (a reader or writer
|
//! whichever it goes through. The client's generic methods (a reader or writer
|
||||||
@@ -26,7 +28,8 @@ use proton_drive_rs::proton_sdk::account::Quota;
|
|||||||
use proton_drive_rs::proton_sdk::error::Result;
|
use proton_drive_rs::proton_sdk::error::Result;
|
||||||
use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid};
|
use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid};
|
||||||
use proton_drive_rs::{
|
use proton_drive_rs::{
|
||||||
DriveEvent, DriveEventScopeId, Node, NodeMoveItem, ProtonDriveClient, RevisionReader, Thumbnail,
|
Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem, ProtonDriveClient,
|
||||||
|
RevisionReader, Thumbnail,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`].
|
/// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`].
|
||||||
@@ -154,6 +157,12 @@ pub(crate) trait DriveApi: Send + Sync {
|
|||||||
/// The account's storage, across every Proton product.
|
/// The account's storage, across every Proton product.
|
||||||
async fn quota(&self) -> Result<Quota>;
|
async fn quota(&self) -> Result<Quota>;
|
||||||
|
|
||||||
|
/// Every computer that backs up to the account.
|
||||||
|
async fn enumerate_devices(&self) -> Result<Vec<Device>>;
|
||||||
|
|
||||||
|
/// Register a computer, with a root folder of its own named `name`.
|
||||||
|
async fn create_device(&self, name: &str, device_type: DeviceType) -> Result<Device>;
|
||||||
|
|
||||||
/// Called by the drain once it has let go of the blob of an upload to
|
/// Called by the drain once it has let go of the blob of an upload to
|
||||||
/// `uid` that landed, or retired the create that made `uid`, before the
|
/// `uid` that landed, or retired the create that made `uid`, before the
|
||||||
/// tree has the node, so a simulation can hold it there. Blocks the drain
|
/// tree has the node, so a simulation can hold it there. Blocks the drain
|
||||||
@@ -470,4 +479,12 @@ impl DriveApi for ProtonDriveClient {
|
|||||||
async fn quota(&self) -> Result<Quota> {
|
async fn quota(&self) -> Result<Quota> {
|
||||||
ProtonDriveClient::quota(self).await
|
ProtonDriveClient::quota(self).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn enumerate_devices(&self) -> Result<Vec<Device>> {
|
||||||
|
ProtonDriveClient::enumerate_devices(self).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_device(&self, name: &str, device_type: DeviceType) -> Result<Device> {
|
||||||
|
ProtonDriveClient::create_device(self, name, device_type).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -329,6 +329,7 @@ mod tests {
|
|||||||
online: true,
|
online: true,
|
||||||
session_expired: false,
|
session_expired: false,
|
||||||
tokens_unsaved: false,
|
tokens_unsaved: false,
|
||||||
|
version: "3.3.0".into(),
|
||||||
pending_uploads: 1,
|
pending_uploads: 1,
|
||||||
pending_changes: 0,
|
pending_changes: 0,
|
||||||
};
|
};
|
||||||
|
|||||||
+111
-34
@@ -4227,8 +4227,10 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Pin the node at mountpoint-relative `rel`. A file downloads its full
|
/// Pin the node at mountpoint-relative `rel`. A file downloads its full
|
||||||
/// plaintext into the content cache; a folder records a recursive pin and
|
/// plaintext into the content cache before this returns. A folder records a
|
||||||
/// downloads every descendant file (selective sync). Returns a human message.
|
/// recursive pin and downloads every descendant file on a background thread,
|
||||||
|
/// reported as a job: a big folder far outlasts the socket read timeout.
|
||||||
|
/// Returns a human message.
|
||||||
fn pin(&self, rel: &Path) -> CoreResult<String> {
|
fn pin(&self, rel: &Path) -> CoreResult<String> {
|
||||||
let (ino, uid) = self.resolve(rel)?;
|
let (ino, uid) = self.resolve(rel)?;
|
||||||
let (name, is_folder, mtime, size) = {
|
let (name, is_folder, mtime, size) = {
|
||||||
@@ -4245,34 +4247,93 @@ impl Core {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
if is_folder {
|
if is_folder {
|
||||||
|
// Offline, every download would fail and the folder would only look
|
||||||
|
// kept. Refuse instead, as a file pin does.
|
||||||
|
if !self.is_online() {
|
||||||
|
return Err(CoreError::offline());
|
||||||
|
}
|
||||||
// Record the recursive pin first so every descendant is eviction-
|
// Record the recursive pin first so every descendant is eviction-
|
||||||
// exempt before we start filling the cache with the subtree.
|
// exempt before we start filling the cache with the subtree.
|
||||||
self.cache
|
self.cache
|
||||||
.add_pin(&uid, rel, true)
|
.add_pin(&uid, rel, true)
|
||||||
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
||||||
let n = self.pin_subtree(ino)?;
|
let core = self.clone();
|
||||||
return Ok(format!("{name} ({n} files)"));
|
let folder = name.clone();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("pin-folder".into())
|
||||||
|
.spawn(move || core.pin_subtree(ino, &uid, &folder))
|
||||||
|
.map_err(|e| CoreError::internal(format!("spawn pin: {e}")))?;
|
||||||
|
return Ok(format!("{name}: downloading its files in the background"));
|
||||||
}
|
}
|
||||||
let bytes = self
|
self.fetch_content(&uid, &name, mtime, size)?;
|
||||||
.download_file_tracked(&uid, &name, size)
|
|
||||||
.map_err(|e| CoreError::from_api(&e, "download"))?;
|
|
||||||
self.cache
|
|
||||||
.store(&uid, mtime, size, &bytes)
|
|
||||||
.map_err(|e| CoreError::internal(format!("cache store: {e}")))?;
|
|
||||||
self.cache
|
self.cache
|
||||||
.add_pin(&uid, rel, false)
|
.add_pin(&uid, rel, false)
|
||||||
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
||||||
Ok(name)
|
Ok(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download and cache every file in the subtree rooted at folder `ino`,
|
/// Download and cache every file in the subtree rooted at the pinned folder
|
||||||
/// returning the count cached (already-fresh blobs counted, not re-fetched).
|
/// `ino` (`folder`, named `name`), as a job the front ends show. Walks the
|
||||||
/// Walks the tree depth-first, enumerating each folder so a cold subtree is
|
/// tree depth-first, enumerating each folder so a cold subtree is fully
|
||||||
/// fully discovered; the lock is dropped before each network download.
|
/// discovered; the lock is dropped before each network download. Stops when
|
||||||
fn pin_subtree(&self, ino: u64) -> CoreResult<usize> {
|
/// the folder is unpinned or the daemon stops.
|
||||||
|
fn pin_subtree(&self, ino: u64, folder: &NodeUid, name: &str) {
|
||||||
|
let job = self
|
||||||
|
.transfers
|
||||||
|
.begin_job(format!("Making {name} available offline"));
|
||||||
|
match self.pin_subtree_files(ino) {
|
||||||
|
Ok(files) => {
|
||||||
|
job.set_total(files.len() as u64);
|
||||||
|
let mut failed = 0usize;
|
||||||
|
for (uid, file, mtime, size) in files {
|
||||||
|
if self.shutdown.is_stopping() || !self.cache.is_pinned(folder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job.detail(file.clone());
|
||||||
|
if let Err(e) = self.fetch_pinned(&uid, &file, mtime, size) {
|
||||||
|
warn!(%uid, error = %e, "pin folder: download failed");
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
job.step();
|
||||||
|
}
|
||||||
|
info!(folder = %name, failed, "pin folder: done");
|
||||||
|
}
|
||||||
|
Err(e) => warn!(folder = %name, error = %e, "pin folder: enumerate failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache the file `uid` for a folder pin. Its `mtime` and `size` were read
|
||||||
|
/// before the download, and a create landing or a remote edit can change
|
||||||
|
/// them while it runs. The blob is then stored under the old ones and reads
|
||||||
|
/// as stale, so the file is listed as kept but not available offline. Fetch
|
||||||
|
/// it again under the new ones, a few times at most.
|
||||||
|
fn fetch_pinned(&self, uid: &NodeUid, name: &str, mtime: i64, size: u64) -> CoreResult<()> {
|
||||||
|
let mut fetched = (mtime, size);
|
||||||
|
for _ in 0..3 {
|
||||||
|
self.fetch_content(uid, name, fetched.0, fetched.1)?;
|
||||||
|
let current = {
|
||||||
|
let st = self.state();
|
||||||
|
st.by_uid
|
||||||
|
.get(uid)
|
||||||
|
.and_then(|ino| st.entries.get(ino))
|
||||||
|
.map(|e| (e.node.modification_time, node_size(&e.node)))
|
||||||
|
};
|
||||||
|
match current {
|
||||||
|
Some(now) if now != fetched => fetched = now,
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every file in the subtree rooted at folder `ino`.
|
||||||
|
fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<(NodeUid, String, i64, u64)>> {
|
||||||
let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new();
|
let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new();
|
||||||
let mut stack = vec![ino];
|
let mut stack = vec![ino];
|
||||||
while let Some(dir) = stack.pop() {
|
while let Some(dir) = stack.pop() {
|
||||||
|
if self.shutdown.is_stopping() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
self.ensure_children(dir)
|
self.ensure_children(dir)
|
||||||
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
||||||
let st = self.state();
|
let st = self.state();
|
||||||
@@ -4293,22 +4354,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut count = 0;
|
Ok(files)
|
||||||
for (uid, name, mtime, size) in files {
|
|
||||||
if self.cache.is_cached(&uid, mtime, size) {
|
|
||||||
count += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match self.download_file_tracked(&uid, &name, size) {
|
|
||||||
Ok(bytes) => {
|
|
||||||
if self.cache.store(&uid, mtime, size, &bytes).is_ok() {
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => warn!(%uid, error = %e, "pin subtree: download failed"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(count)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch a thumbnail of `ttype` for the file at `ino`, served from the cache
|
/// Fetch a thumbnail of `ttype` for the file at `ino`, served from the cache
|
||||||
@@ -4354,6 +4400,8 @@ impl Core {
|
|||||||
|
|
||||||
/// Unpin the node at `rel`, evicting its cached content. For a folder, also
|
/// Unpin the node at `rel`, evicting its cached content. For a folder, also
|
||||||
/// evicts every descendant's cached blob (the subtree is no longer kept).
|
/// evicts every descendant's cached blob (the subtree is no longer kept).
|
||||||
|
/// A node kept only because a folder above it is pinned is refused: there
|
||||||
|
/// is no pin of its own to remove, and it would stay kept all the same.
|
||||||
fn unpin(&self, rel: &Path) -> CoreResult<String> {
|
fn unpin(&self, rel: &Path) -> CoreResult<String> {
|
||||||
let (ino, uid) = self.resolve(rel)?;
|
let (ino, uid) = self.resolve(rel)?;
|
||||||
let (name, is_folder) = {
|
let (name, is_folder) = {
|
||||||
@@ -4363,14 +4411,24 @@ impl Core {
|
|||||||
.map(|e| (e.node.name.clone(), e.node.is_folder()))
|
.map(|e| (e.node.name.clone(), e.node.is_folder()))
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
};
|
};
|
||||||
|
if self.pinned_by_folder(ino) {
|
||||||
|
return Err(CoreError::conflict(format!(
|
||||||
|
"{name} is kept offline because a folder above it is; unpin that folder instead"
|
||||||
|
)));
|
||||||
|
}
|
||||||
self.cache
|
self.cache
|
||||||
.remove_pin(&uid)
|
.remove_pin(&uid)
|
||||||
.map_err(|e| CoreError::from_api(&e, "unpin"))?;
|
.map_err(|e| CoreError::from_api(&e, "unpin"))?;
|
||||||
// A recursively-pinned folder's descendants were eviction-exempt; now
|
// A recursively-pinned folder's descendants were eviction-exempt; now
|
||||||
// that the pin is gone, reclaim their blobs eagerly instead of waiting
|
// that the pin is gone, reclaim their blobs eagerly instead of waiting
|
||||||
// for budget pressure. Descendants come from the DB node tree.
|
// for budget pressure. Descendants come from the DB node tree. One
|
||||||
|
// pinned on its own, or under a pinned subfolder, is still kept.
|
||||||
if is_folder && let Ok(uids) = self.db.descendants(&uid.to_string()) {
|
if is_folder && let Ok(uids) = self.db.descendants(&uid.to_string()) {
|
||||||
|
let still_pinned = self.pinned_set();
|
||||||
for s in uids {
|
for s in uids {
|
||||||
|
if still_pinned.contains(&s) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if let Some(u) = parse_uid(&s) {
|
if let Some(u) = parse_uid(&s) {
|
||||||
self.cache.evict(&u);
|
self.cache.evict(&u);
|
||||||
self.evict_reader(&u);
|
self.evict_reader(&u);
|
||||||
@@ -4380,6 +4438,20 @@ impl Core {
|
|||||||
Ok(name)
|
Ok(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the node at `ino` is kept offline because a folder above it is
|
||||||
|
/// pinned, whether or not it is also pinned on its own.
|
||||||
|
fn pinned_by_folder(&self, ino: u64) -> bool {
|
||||||
|
let parent = {
|
||||||
|
let st = self.state();
|
||||||
|
st.entries
|
||||||
|
.get(&ino)
|
||||||
|
.filter(|e| e.parent != ino)
|
||||||
|
.and_then(|e| st.entries.get(&e.parent))
|
||||||
|
.map(|p| p.uid.clone())
|
||||||
|
};
|
||||||
|
parent.is_some_and(|uid| self.cache.is_pinned(&uid))
|
||||||
|
}
|
||||||
|
|
||||||
/// A Photos API handle sharing this Core's Drive client and session, so it
|
/// A Photos API handle sharing this Core's Drive client and session, so it
|
||||||
/// reuses the daemon's single authenticated session rather than logging in
|
/// reuses the daemon's single authenticated session rather than logging in
|
||||||
/// again (Proton refresh tokens are single-use). Cheap — the Drive client
|
/// again (Proton refresh tokens are single-use). Cheap — the Drive client
|
||||||
@@ -4392,10 +4464,13 @@ impl Core {
|
|||||||
/// the same lazy remote enumeration `readdir` uses, projected into
|
/// the same lazy remote enumeration `readdir` uses, projected into
|
||||||
/// serializable [`DirEntry`]s (with per-file pin state).
|
/// serializable [`DirEntry`]s (with per-file pin state).
|
||||||
fn list_dir(&self, rel: &Path) -> CoreResult<Vec<DirEntry>> {
|
fn list_dir(&self, rel: &Path) -> CoreResult<Vec<DirEntry>> {
|
||||||
let (ino, _uid) = self.resolve(rel)?;
|
let (ino, uid) = self.resolve(rel)?;
|
||||||
self.ensure_children(ino)
|
self.ensure_children(ino)
|
||||||
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
||||||
self.settle_listing_sizes(ino);
|
self.settle_listing_sizes(ino);
|
||||||
|
// Every entry shares this folder as its parent, so one lookup says
|
||||||
|
// whether they are all kept through it.
|
||||||
|
let pinned_by_folder = self.cache.is_pinned(&uid);
|
||||||
// Snapshot the listing, then drop the lock before touching the on-disk
|
// Snapshot the listing, then drop the lock before touching the on-disk
|
||||||
// pin registry so a slow disk read doesn't block FUSE metadata ops.
|
// pin registry so a slow disk read doesn't block FUSE metadata ops.
|
||||||
let rows: Vec<(String, bool, u64, i64, NodeUid, u64)> = {
|
let rows: Vec<(String, bool, u64, i64, NodeUid, u64)> = {
|
||||||
@@ -4427,7 +4502,8 @@ impl Core {
|
|||||||
is_dir,
|
is_dir,
|
||||||
size,
|
size,
|
||||||
modified,
|
modified,
|
||||||
pinned: self.cache.is_pinned(&uid),
|
pinned: pinned_by_folder || self.cache.is_pinned(&uid),
|
||||||
|
pinned_by_folder,
|
||||||
cached: !is_dir && self.cache.is_cached(&uid, modified, size),
|
cached: !is_dir && self.cache.is_cached(&uid, modified, size),
|
||||||
uid: uid.to_string(),
|
uid: uid.to_string(),
|
||||||
// Listing entries live in the requested dir; the caller derives
|
// Listing entries live in the requested dir; the caller derives
|
||||||
@@ -5319,6 +5395,7 @@ impl Core {
|
|||||||
// A trashed node can't be pinned or served from the mount, so its
|
// A trashed node can't be pinned or served from the mount, so its
|
||||||
// content is never current cache: report neither.
|
// content is never current cache: report neither.
|
||||||
pinned: false,
|
pinned: false,
|
||||||
|
pinned_by_folder: false,
|
||||||
cached: false,
|
cached: false,
|
||||||
uid: item.uid,
|
uid: item.uid,
|
||||||
path: String::new(),
|
path: String::new(),
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ use proton_drive_rs::NodeKind;
|
|||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use super::state::State;
|
||||||
use super::sync::mirror_subtree_unsynced;
|
use super::sync::mirror_subtree_unsynced;
|
||||||
use super::{Core, is_local_uid, parse_uid};
|
use super::{Core, is_local_uid, parse_uid};
|
||||||
|
|
||||||
@@ -130,6 +131,24 @@ fn rename_noreplace(from: &Path, to: &Path) -> std::io::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether inode `ino` is `root` or lies under it in the tree. Bounded, so a
|
||||||
|
/// parent cycle in corrupt data cannot hang the caller.
|
||||||
|
fn ino_is_under(st: &State, mut ino: u64, root: u64) -> bool {
|
||||||
|
for _ in 0..MAX_TREE_DEPTH {
|
||||||
|
if ino == root {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match st.entries.get(&ino) {
|
||||||
|
Some(entry) if entry.parent != ino => ino = entry.parent,
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How deep [`ino_is_under`] walks before it gives up.
|
||||||
|
const MAX_TREE_DEPTH: usize = 1024;
|
||||||
|
|
||||||
/// Whether `uid` is `root` or lies under it, as far as the nodes table knows.
|
/// Whether `uid` is `root` or lies under it, as far as the nodes table knows.
|
||||||
fn under(core: &Core, root: &NodeUid, uid: &str) -> bool {
|
fn under(core: &Core, root: &NodeUid, uid: &str) -> bool {
|
||||||
let root = root.to_string();
|
let root = root.to_string();
|
||||||
@@ -336,7 +355,7 @@ impl Core {
|
|||||||
core.require_uid_writable(authority)
|
core.require_uid_writable(authority)
|
||||||
.map_err(|e| core.errno_error(e, "move access"))?;
|
.map_err(|e| core.errno_error(e, "move access"))?;
|
||||||
}
|
}
|
||||||
if self.has_pending_work_under(&uid)? {
|
if self.has_pending_work_under(ino, &uid)? {
|
||||||
return Err(CoreError::conflict(
|
return Err(CoreError::conflict(
|
||||||
"it has changes that are not uploaded yet; wait for the upload to finish",
|
"it has changes that are not uploaded yet; wait for the upload to finish",
|
||||||
));
|
));
|
||||||
@@ -410,10 +429,11 @@ impl Core {
|
|||||||
Ok(uid)
|
Ok(uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the queue owes Drive anything for `uid` or a node under it, or a
|
/// Whether the queue owes Drive anything for `uid` (inode `ino`) or a node
|
||||||
/// file under it is open for writing. Moving such a node away would strand
|
/// under it, or a file under it is open for writing. Moving such a node away
|
||||||
/// the pending change against a path that no longer leads to it.
|
/// would strand the pending change against a path that no longer leads to
|
||||||
fn has_pending_work_under(&self, uid: &NodeUid) -> CoreResult<bool> {
|
/// it.
|
||||||
|
fn has_pending_work_under(&self, ino: u64, uid: &NodeUid) -> CoreResult<bool> {
|
||||||
let ops = self
|
let ops = self
|
||||||
.db
|
.db
|
||||||
.pending_ops()
|
.pending_ops()
|
||||||
@@ -427,12 +447,18 @@ impl Core {
|
|||||||
}) {
|
}) {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
// Collected first so no State lock is held across the nodes-table reads.
|
// Open handles are placed in the tree by inode, not by uid in the nodes
|
||||||
let mut writing = Vec::new();
|
// table. A file opened just after its create landed holds the
|
||||||
|
// placeholder uid until the tree catches up, and the nodes table no
|
||||||
|
// longer has that uid, so the move went ahead under an open write.
|
||||||
|
let mut writing = false;
|
||||||
self.for_each_state(|st| {
|
self.for_each_state(|st| {
|
||||||
writing.extend(st.active_writes.values().map(|w| w.uid.to_string()));
|
writing = st
|
||||||
|
.active_writes
|
||||||
|
.keys()
|
||||||
|
.any(|&open| ino_is_under(st, open, ino));
|
||||||
});
|
});
|
||||||
Ok(writing.iter().any(|w| under(self, uid, w)))
|
Ok(writing)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bring the source location level with a move Drive has made.
|
/// Bring the source location level with a move Drive has made.
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
|||||||
size,
|
size,
|
||||||
modified: n.modification_time,
|
modified: n.modification_time,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
|
pinned_by_folder: false,
|
||||||
cached: false,
|
cached: false,
|
||||||
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
|
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
|
||||||
uid: n.uid.to_string(),
|
uid: n.uid.to_string(),
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ impl Daemon {
|
|||||||
/// cache and the database the last run left.
|
/// cache and the database the last run left.
|
||||||
pub(crate) fn start(dir: &Path, client: FakeClient) -> io::Result<Self> {
|
pub(crate) fn start(dir: &Path, client: FakeClient) -> io::Result<Self> {
|
||||||
log_to_test_output();
|
log_to_test_output();
|
||||||
|
isolate_from_the_developer();
|
||||||
let mountpoint = dir.join("mnt");
|
let mountpoint = dir.join("mnt");
|
||||||
let socket = dir.join("control.sock");
|
let socket = dir.join("control.sock");
|
||||||
std::fs::create_dir_all(&mountpoint)?;
|
std::fs::create_dir_all(&mountpoint)?;
|
||||||
@@ -138,6 +139,40 @@ impl Daemon {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write the new file `path` under the mount, and let the drain send its
|
||||||
|
/// create only once the create holds the bytes. Otherwise a drain worker
|
||||||
|
/// still busy with the op before can claim the create before the release
|
||||||
|
/// attaches them, which the kernel sends after `close` returns. The file
|
||||||
|
/// then goes out empty, and the bytes follow as a revision, past a hook on
|
||||||
|
/// the next create.
|
||||||
|
pub(crate) fn write_new(&self, path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||||
|
let rel = path
|
||||||
|
.strip_prefix(&self.mountpoint)
|
||||||
|
.map_err(io::Error::other)?
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
let paused = |paused| Request::SetSyncPaused {
|
||||||
|
paused,
|
||||||
|
until: None,
|
||||||
|
};
|
||||||
|
self.request(&paused(true))?;
|
||||||
|
std::fs::write(path, bytes)?;
|
||||||
|
let attached = wait_until(Duration::from_secs(30), || {
|
||||||
|
self.pending().is_ok_and(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.any(|item| item.kind == "create" && item.path == rel && item.exportable)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
self.request(&paused(false))?;
|
||||||
|
match attached {
|
||||||
|
true => Ok(()),
|
||||||
|
false => Err(io::Error::other(format!(
|
||||||
|
"{rel}: create never held the bytes"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop the daemon and start it again on the same state and link.
|
/// Stop the daemon and start it again on the same state and link.
|
||||||
pub(crate) fn restart(mut self) -> io::Result<Self> {
|
pub(crate) fn restart(mut self) -> io::Result<Self> {
|
||||||
self.shut_down()?;
|
self.shut_down()?;
|
||||||
@@ -224,6 +259,33 @@ impl tracing::field::Visit for Message {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Point the XDG directories of the whole test process at a scratch
|
||||||
|
/// directory, once, before the first daemon starts.
|
||||||
|
///
|
||||||
|
/// The daemon reads `config.json` through `AppDirs` in a few places (the
|
||||||
|
/// adopted device, the ignore rules, the profile), and those would otherwise
|
||||||
|
/// be the developer's own: an adopted device of their real account made every
|
||||||
|
/// sync folder fail to register against the fake Drive.
|
||||||
|
fn isolate_from_the_developer() {
|
||||||
|
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||||
|
ONCE.call_once(|| {
|
||||||
|
let home = scratch("xdg");
|
||||||
|
for (var, sub) in [
|
||||||
|
("XDG_CONFIG_HOME", "config"),
|
||||||
|
("XDG_STATE_HOME", "state"),
|
||||||
|
("XDG_CACHE_HOME", "cache"),
|
||||||
|
("XDG_DATA_HOME", "data"),
|
||||||
|
] {
|
||||||
|
let path = home.join(sub);
|
||||||
|
std::fs::create_dir_all(&path).expect("create a simulation XDG directory");
|
||||||
|
// SAFETY: the simulations run one at a time (`--test-threads=1`,
|
||||||
|
// docs/TESTING.md), and this runs before the first of them starts a
|
||||||
|
// daemon, so no other thread reads the environment meanwhile.
|
||||||
|
unsafe { std::env::set_var(var, &path) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// A client for what `DriveApi` does not cover (sharing, Photos, devices),
|
/// A client for what `DriveApi` does not cover (sharing, Photos, devices),
|
||||||
/// pointed at a closed local port so none of it leaves the machine.
|
/// pointed at a closed local port so none of it leaves the machine.
|
||||||
fn offline_client() -> ProtonDriveClient {
|
fn offline_client() -> ProtonDriveClient {
|
||||||
@@ -759,7 +821,7 @@ mod tests {
|
|||||||
let path = daemon.mountpoint.join("c.bin");
|
let path = daemon.mountpoint.join("c.bin");
|
||||||
|
|
||||||
let create = daemon.client.hold_reply_to_next_create();
|
let create = daemon.client.hold_reply_to_next_create();
|
||||||
std::fs::write(&path, b"abc").unwrap();
|
daemon.write_new(&path, b"abc").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
||||||
let uid = drive.lookup("c.bin").unwrap();
|
let uid = drive.lookup("c.bin").unwrap();
|
||||||
let landing = daemon.client.hold_next_landing(&uid);
|
let landing = daemon.client.hold_next_landing(&uid);
|
||||||
@@ -805,7 +867,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let create = daemon.client.hold_reply_to_next_create();
|
let create = daemon.client.hold_reply_to_next_create();
|
||||||
std::fs::write(mnt.join("a.txt"), b"old").unwrap();
|
daemon.write_new(&mnt.join("a.txt"), b"old").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
||||||
let uid = drive.lookup("a.txt").unwrap();
|
let uid = drive.lookup("a.txt").unwrap();
|
||||||
let landing = daemon.client.hold_next_landing(&uid);
|
let landing = daemon.client.hold_next_landing(&uid);
|
||||||
@@ -843,7 +905,7 @@ mod tests {
|
|||||||
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
let mnt = daemon.mountpoint.clone();
|
let mnt = daemon.mountpoint.clone();
|
||||||
let create = daemon.client.hold_reply_to_next_create();
|
let create = daemon.client.hold_reply_to_next_create();
|
||||||
std::fs::write(mnt.join("a.txt"), b"old").unwrap();
|
daemon.write_new(&mnt.join("a.txt"), b"old").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
||||||
std::fs::write(mnt.join("a.txt"), b"newer").unwrap();
|
std::fs::write(mnt.join("a.txt"), b"newer").unwrap();
|
||||||
let uid = drive.lookup("a.txt").unwrap();
|
let uid = drive.lookup("a.txt").unwrap();
|
||||||
@@ -1026,7 +1088,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let create = daemon.client.hold_reply_to_next_create();
|
let create = daemon.client.hold_reply_to_next_create();
|
||||||
std::fs::write(mnt.join("x/c.bin"), b"abc").unwrap();
|
daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
||||||
std::fs::rename(mnt.join("x/c.bin"), mnt.join("y/d")).unwrap();
|
std::fs::rename(mnt.join("x/c.bin"), mnt.join("y/d")).unwrap();
|
||||||
let uid = drive.lookup("x/c.bin").unwrap();
|
let uid = drive.lookup("x/c.bin").unwrap();
|
||||||
@@ -1104,7 +1166,7 @@ mod tests {
|
|||||||
take_logged();
|
take_logged();
|
||||||
|
|
||||||
let create = daemon.client.hold_reply_to_next_create();
|
let create = daemon.client.hold_reply_to_next_create();
|
||||||
std::fs::write(mnt.join("x/c.bin"), b"abc").unwrap();
|
daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
||||||
let uid = drive.lookup("x/c.bin").unwrap();
|
let uid = drive.lookup("x/c.bin").unwrap();
|
||||||
// The read-back after the create, the one adopting it, and the size
|
// The read-back after the create, the one adopting it, and the size
|
||||||
@@ -1152,7 +1214,7 @@ mod tests {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
let create = daemon.client.hold_reply_to_next_create();
|
let create = daemon.client.hold_reply_to_next_create();
|
||||||
std::fs::write(mnt.join("x/c.bin"), b"abc").unwrap();
|
daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
||||||
let uid = drive.lookup("x/c.bin").unwrap();
|
let uid = drive.lookup("x/c.bin").unwrap();
|
||||||
let read_back = daemon.client.hold_next_read(&uid);
|
let read_back = daemon.client.hold_next_read(&uid);
|
||||||
@@ -1195,7 +1257,7 @@ mod tests {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
let create = daemon.client.hold_reply_to_next_create();
|
let create = daemon.client.hold_reply_to_next_create();
|
||||||
std::fs::write(mnt.join("x/c.bin"), b"abc").unwrap();
|
daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
||||||
// Long enough for the next event poll to bring the file's event, which
|
// Long enough for the next event poll to bring the file's event, which
|
||||||
// drops the folder's listing.
|
// drops the folder's listing.
|
||||||
@@ -1320,7 +1382,9 @@ mod tests {
|
|||||||
let folder = drive.lookup("x").unwrap();
|
let folder = drive.lookup("x").unwrap();
|
||||||
|
|
||||||
let create = daemon.client.hold_next_create();
|
let create = daemon.client.hold_next_create();
|
||||||
std::fs::write(mnt.join("x/removed-while-uploading"), b"abc").unwrap();
|
daemon
|
||||||
|
.write_new(&mnt.join("x/removed-while-uploading"), b"abc")
|
||||||
|
.unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
||||||
std::fs::remove_file(mnt.join("x/removed-while-uploading")).unwrap();
|
std::fs::remove_file(mnt.join("x/removed-while-uploading")).unwrap();
|
||||||
std::fs::remove_dir(mnt.join("x")).unwrap();
|
std::fs::remove_dir(mnt.join("x")).unwrap();
|
||||||
@@ -1565,7 +1629,9 @@ mod tests {
|
|||||||
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
|
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
|
||||||
|
|
||||||
daemon.client.lose_reply_to_next_create();
|
daemon.client.lose_reply_to_next_create();
|
||||||
std::fs::write(daemon.mountpoint.join("f.txt"), b"once").unwrap();
|
daemon
|
||||||
|
.write_new(&daemon.mountpoint.join("f.txt"), b"once")
|
||||||
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
wait_until(Duration::from_secs(30), || {
|
wait_until(Duration::from_secs(30), || {
|
||||||
daemon.pending().is_ok_and(|items| items.is_empty())
|
daemon.pending().is_ok_and(|items| items.is_empty())
|
||||||
@@ -1595,7 +1661,7 @@ mod tests {
|
|||||||
|
|
||||||
let held = daemon.client.hold_reply_to_next_create();
|
let held = daemon.client.hold_reply_to_next_create();
|
||||||
daemon.client.lose_reply_to_next_create();
|
daemon.client.lose_reply_to_next_create();
|
||||||
std::fs::write(&path, b"first").unwrap();
|
daemon.write_new(&path, b"first").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || held.reached()));
|
assert!(wait_until(Duration::from_secs(30), || held.reached()));
|
||||||
std::fs::write(&path, b"second").unwrap();
|
std::fs::write(&path, b"second").unwrap();
|
||||||
drop(held);
|
drop(held);
|
||||||
@@ -1627,7 +1693,7 @@ mod tests {
|
|||||||
let path = daemon.mountpoint.join("f.txt");
|
let path = daemon.mountpoint.join("f.txt");
|
||||||
|
|
||||||
daemon.client.lose_reply_to_next_create();
|
daemon.client.lose_reply_to_next_create();
|
||||||
std::fs::write(&path, b"gone").unwrap();
|
daemon.write_new(&path, b"gone").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || {
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
drive.tree().contains_key("f.txt")
|
drive.tree().contains_key("f.txt")
|
||||||
}));
|
}));
|
||||||
@@ -1660,7 +1726,7 @@ mod tests {
|
|||||||
|
|
||||||
let held = daemon.client.hold_reply_to_next_create();
|
let held = daemon.client.hold_reply_to_next_create();
|
||||||
daemon.client.lose_reply_to_next_create();
|
daemon.client.lose_reply_to_next_create();
|
||||||
std::fs::write(&path, b"first").unwrap();
|
daemon.write_new(&path, b"first").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || held.reached()));
|
assert!(wait_until(Duration::from_secs(30), || held.reached()));
|
||||||
std::fs::write(&path, b"second").unwrap();
|
std::fs::write(&path, b"second").unwrap();
|
||||||
drop(held);
|
drop(held);
|
||||||
@@ -1700,7 +1766,9 @@ mod tests {
|
|||||||
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
|
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
|
||||||
|
|
||||||
daemon.client.lose_reply_to_next_create();
|
daemon.client.lose_reply_to_next_create();
|
||||||
std::fs::write(daemon.mountpoint.join("f.txt"), b"first").unwrap();
|
daemon
|
||||||
|
.write_new(&daemon.mountpoint.join("f.txt"), b"first")
|
||||||
|
.unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || {
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
daemon
|
daemon
|
||||||
.pending()
|
.pending()
|
||||||
@@ -1739,7 +1807,9 @@ mod tests {
|
|||||||
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
|
||||||
daemon.client.lose_reply_to_next_create();
|
daemon.client.lose_reply_to_next_create();
|
||||||
std::fs::write(daemon.mountpoint.join("f.txt"), b"first").unwrap();
|
daemon
|
||||||
|
.write_new(&daemon.mountpoint.join("f.txt"), b"first")
|
||||||
|
.unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || {
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
daemon
|
daemon
|
||||||
.pending()
|
.pending()
|
||||||
@@ -1833,7 +1903,7 @@ mod tests {
|
|||||||
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
|
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
|
||||||
|
|
||||||
let held = daemon.client.hold_reply_to_next_create();
|
let held = daemon.client.hold_reply_to_next_create();
|
||||||
std::fs::write(mount.join("c.bin"), b"first").unwrap();
|
daemon.write_new(&mount.join("c.bin"), b"first").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || held.reached()));
|
assert!(wait_until(Duration::from_secs(30), || held.reached()));
|
||||||
std::fs::rename(mount.join("c.bin"), mount.join("d")).unwrap();
|
std::fs::rename(mount.join("c.bin"), mount.join("d")).unwrap();
|
||||||
std::fs::write(mount.join("c.bin"), b"second").unwrap();
|
std::fs::write(mount.join("c.bin"), b"second").unwrap();
|
||||||
@@ -2099,4 +2169,245 @@ mod tests {
|
|||||||
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
|
fn a_pinned_folder_keeps_every_file_under_it_until_it_is_unpinned() {
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("pin-folder");
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
std::fs::create_dir_all(daemon.mountpoint.join("Docs/sub")).unwrap();
|
||||||
|
std::fs::write(daemon.mountpoint.join("Docs/a.txt"), b"alpha").unwrap();
|
||||||
|
std::fs::write(daemon.mountpoint.join("Docs/sub/b.txt"), b"beta").unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
daemon.pending().is_ok_and(|items| items.is_empty())
|
||||||
|
&& drive.lookup("Docs/sub/b.txt").is_some()
|
||||||
|
}));
|
||||||
|
daemon.request(&Request::PurgeCache).unwrap();
|
||||||
|
let entry = |folder: &str, name: &str| match daemon.request(&Request::ListDir {
|
||||||
|
path: folder.into(),
|
||||||
|
}) {
|
||||||
|
Ok(Response::Entries { entries }) => entries.into_iter().find(|e| e.name == name),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
assert!(entry("Docs", "a.txt").is_some_and(|e| !e.cached && !e.pinned));
|
||||||
|
|
||||||
|
daemon
|
||||||
|
.request(&Request::Pin {
|
||||||
|
path: "Docs".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
wait_until(Duration::from_secs(30), || {
|
||||||
|
entry("Docs", "a.txt").is_some_and(|e| e.cached)
|
||||||
|
&& entry("Docs/sub", "b.txt").is_some_and(|e| e.cached)
|
||||||
|
}),
|
||||||
|
"the folder's files were downloaded in the background"
|
||||||
|
);
|
||||||
|
let a = entry("Docs", "a.txt").unwrap();
|
||||||
|
assert!(a.pinned && a.pinned_by_folder);
|
||||||
|
assert!(entry("", "Docs").is_some_and(|e| e.pinned && !e.pinned_by_folder));
|
||||||
|
assert!(
|
||||||
|
daemon
|
||||||
|
.request(&Request::Unpin {
|
||||||
|
path: "Docs/a.txt".into(),
|
||||||
|
})
|
||||||
|
.is_err(),
|
||||||
|
"a file kept through its folder cannot be unpinned on its own"
|
||||||
|
);
|
||||||
|
|
||||||
|
daemon
|
||||||
|
.request(&Request::Unpin {
|
||||||
|
path: "Docs".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let a = entry("Docs", "a.txt").unwrap();
|
||||||
|
assert!(!a.pinned && !a.pinned_by_folder && !a.cached);
|
||||||
|
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
|
fn a_folder_pinned_while_a_create_in_it_lands_keeps_that_file() {
|
||||||
|
// The pin's download of a file started before the file's landing set
|
||||||
|
// its modification time from Drive, and ended after it: the blob was
|
||||||
|
// stored under the old time, and the file never read as cached (B208).
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("pin-folder-landing");
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
std::fs::create_dir(daemon.mountpoint.join("Docs")).unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
daemon.pending().is_ok_and(|items| items.is_empty())
|
||||||
|
}));
|
||||||
|
let entry = || match daemon.request(&Request::ListDir {
|
||||||
|
path: "Docs".into(),
|
||||||
|
}) {
|
||||||
|
Ok(Response::Entries { entries }) => entries.into_iter().find(|e| e.name == "a.txt"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drive stamps the file with the time it landed, a second after it
|
||||||
|
// was written, so the landing moves its modification time.
|
||||||
|
let paused = |paused| Request::SetSyncPaused {
|
||||||
|
paused,
|
||||||
|
until: None,
|
||||||
|
};
|
||||||
|
daemon.request(&paused(true)).unwrap();
|
||||||
|
std::fs::write(daemon.mountpoint.join("Docs/a.txt"), b"alpha").unwrap();
|
||||||
|
std::thread::sleep(Duration::from_millis(1100));
|
||||||
|
let landing = daemon.client.hold_next_landing_of_any();
|
||||||
|
daemon.request(&paused(false)).unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || landing.reached()));
|
||||||
|
let real = drive.lookup("Docs/a.txt").unwrap();
|
||||||
|
let read_back = daemon.client.hold_next_read(&real);
|
||||||
|
drop(landing);
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || read_back.reached()));
|
||||||
|
let written = entry().unwrap().modified;
|
||||||
|
|
||||||
|
// Without the blob the landing kept, the pin downloads the file.
|
||||||
|
daemon.request(&Request::PurgeCache).unwrap();
|
||||||
|
|
||||||
|
let download = daemon.client.hold_next_download(&real);
|
||||||
|
take_logged();
|
||||||
|
daemon
|
||||||
|
.request(&Request::Pin {
|
||||||
|
path: "Docs".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || download.reached()));
|
||||||
|
drop(read_back);
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
entry().is_some_and(|e| e.modified != written)
|
||||||
|
}));
|
||||||
|
drop(download);
|
||||||
|
let mut logged = Vec::new();
|
||||||
|
assert!(
|
||||||
|
wait_until(Duration::from_secs(30), || {
|
||||||
|
logged.extend(take_logged());
|
||||||
|
logged
|
||||||
|
.iter()
|
||||||
|
.any(|line| line.starts_with("pin folder: done"))
|
||||||
|
}),
|
||||||
|
"{logged:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
entry().is_some_and(|e| e.cached),
|
||||||
|
"the file kept through its folder is available offline: {:?}",
|
||||||
|
entry()
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register `local` as a synced folder in `mode` and wait until the daemon
|
||||||
|
/// lists it that way, returning its id.
|
||||||
|
fn sync_folder(daemon: &Daemon, local: &Path, mode: &str) -> i64 {
|
||||||
|
std::fs::create_dir_all(local).unwrap();
|
||||||
|
let path = local.canonicalize().unwrap().to_string_lossy().into_owned();
|
||||||
|
daemon
|
||||||
|
.request(&Request::AddSyncFolder {
|
||||||
|
local_path: path.clone(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let listed = |mode: &str| match daemon.request(&Request::ListSyncFolders) {
|
||||||
|
Ok(Response::SyncFolders { items }) => items
|
||||||
|
.into_iter()
|
||||||
|
.find(|f| {
|
||||||
|
f.local_path == path
|
||||||
|
&& f.mode == mode
|
||||||
|
&& f.pending_mode.is_none()
|
||||||
|
&& f.last_sync > 0
|
||||||
|
})
|
||||||
|
.map(|f| f.id),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
wait_until(Duration::from_secs(30), || listed("mirror").is_some()),
|
||||||
|
"{} was registered: {:?}",
|
||||||
|
local.display(),
|
||||||
|
daemon.request(&Request::ListSyncFolders)
|
||||||
|
);
|
||||||
|
let id = listed("mirror").unwrap();
|
||||||
|
// Reads lag behind creates, so the remote folder the first pass made
|
||||||
|
// is not readable at once; switching modes reads it.
|
||||||
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
|
if mode != "mirror" {
|
||||||
|
daemon
|
||||||
|
.request(&Request::SetSyncFolderMode {
|
||||||
|
id,
|
||||||
|
mode: mode.into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
wait_until(Duration::from_secs(30), || listed(mode).is_some()
|
||||||
|
&& is_mounted(local)),
|
||||||
|
"{} switched to {mode}: {:?}",
|
||||||
|
local.display(),
|
||||||
|
daemon.request(&Request::ListSyncFolders)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
|
fn a_move_out_of_an_on_demand_folder_refuses_a_file_still_open_for_writing() {
|
||||||
|
// The account run's "move refuses a file still being written" case let
|
||||||
|
// a move from an on-demand synced folder into a mirror folder go ahead
|
||||||
|
// while the file was open, so the write landed after the move.
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("move-open-out-of-ondemand");
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
let ondemand = dir.join("ondemand");
|
||||||
|
let mirror = dir.join("mirror");
|
||||||
|
sync_folder(&daemon, &ondemand, "ondemand");
|
||||||
|
sync_folder(&daemon, &mirror, "mirror");
|
||||||
|
|
||||||
|
let source = ondemand.join("open");
|
||||||
|
std::fs::create_dir(&source).unwrap();
|
||||||
|
daemon.request(&Request::SyncNow { id: None }).unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
daemon.pending().is_ok_and(|items| items.is_empty())
|
||||||
|
}));
|
||||||
|
// The account run opened the file a moment after its create landed:
|
||||||
|
// the queue was already empty, and the tree still had the
|
||||||
|
// placeholder uid it gave the file.
|
||||||
|
let landing = daemon.client.hold_next_landing_of_any();
|
||||||
|
{
|
||||||
|
let mut file = std::fs::File::create(source.join("open.txt")).unwrap();
|
||||||
|
std::io::Write::write_all(&mut file, b"before").unwrap();
|
||||||
|
file.sync_all().unwrap();
|
||||||
|
}
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || landing.reached()));
|
||||||
|
|
||||||
|
let mut file = std::fs::OpenOptions::new()
|
||||||
|
.append(true)
|
||||||
|
.open(source.join("open.txt"))
|
||||||
|
.unwrap();
|
||||||
|
std::io::Write::write_all(&mut file, b" and after").unwrap();
|
||||||
|
let moved = daemon.request(&Request::Move {
|
||||||
|
path: source.to_string_lossy().into_owned(),
|
||||||
|
new_parent: mirror
|
||||||
|
.canonicalize()
|
||||||
|
.unwrap()
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned(),
|
||||||
|
});
|
||||||
|
drop(file);
|
||||||
|
drop(landing);
|
||||||
|
match moved {
|
||||||
|
Err(e) => assert!(
|
||||||
|
e.to_string().contains("not uploaded yet"),
|
||||||
|
"refused for another reason: {e}"
|
||||||
|
),
|
||||||
|
Ok(answer) => panic!("the move went ahead: {answer:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -31,6 +31,9 @@
|
|||||||
//! - **A full account** ([`FakeDrive::set_quota`]) refusing uploads with
|
//! - **A full account** ([`FakeDrive::set_quota`]) refusing uploads with
|
||||||
//! `InsufficientQuota`.
|
//! `InsufficientQuota`.
|
||||||
//!
|
//!
|
||||||
|
//! It also holds the account's computers, each with a root folder outside My
|
||||||
|
//! files, so a simulated daemon can register its own and sync a local folder.
|
||||||
|
//!
|
||||||
//! A test can also hold the next read of one node on its way to Drive
|
//! A test can also hold the next read of one node on its way to Drive
|
||||||
//! ([`FakeClient::hold_next_read`]), to make a change while the daemon waits
|
//! ([`FakeClient::hold_next_read`]), to make a change while the daemon waits
|
||||||
//! for the answer, or on its way back ([`FakeClient::hold_answer_to_next_read`]),
|
//! for the answer, or on its way back ([`FakeClient::hold_answer_to_next_read`]),
|
||||||
@@ -54,9 +57,12 @@ use parking_lot::Mutex;
|
|||||||
use proton_drive_rs::proton_sdk::account::Quota;
|
use proton_drive_rs::proton_sdk::account::Quota;
|
||||||
use proton_drive_rs::proton_sdk::api::ResponseCode;
|
use proton_drive_rs::proton_sdk::api::ResponseCode;
|
||||||
use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError, Result};
|
use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError, Result};
|
||||||
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId};
|
use proton_drive_rs::proton_sdk::ids::{
|
||||||
|
DeviceUid, DriveEventId, LinkId, NodeUid, ShareId, VolumeId,
|
||||||
|
};
|
||||||
use proton_drive_rs::{
|
use proton_drive_rs::{
|
||||||
DriveEvent, DriveEventScopeId, Node, NodeKind, NodeMoveItem, RevisionState, Thumbnail,
|
Device as Computer, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeKind, NodeMoveItem,
|
||||||
|
RevisionState, Thumbnail,
|
||||||
};
|
};
|
||||||
use sha1::{Digest as _, Sha1};
|
use sha1::{Digest as _, Sha1};
|
||||||
|
|
||||||
@@ -111,6 +117,9 @@ struct Server {
|
|||||||
block_pattern: Vec<u64>,
|
block_pattern: Vec<u64>,
|
||||||
/// The account's storage limit in bytes.
|
/// The account's storage limit in bytes.
|
||||||
max_space: i64,
|
max_space: i64,
|
||||||
|
/// The account's computers, oldest first, as `(name, type, root folder)`.
|
||||||
|
/// [`Computer`] is not `Clone`, so each answer builds them afresh.
|
||||||
|
devices: Vec<(String, DeviceType, NodeUid)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Server {
|
impl Server {
|
||||||
@@ -162,6 +171,20 @@ impl Server {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `n`th computer of the account, as Drive lists it.
|
||||||
|
fn computer(&self, n: usize) -> Computer {
|
||||||
|
let (name, device_type, root) = &self.devices[n];
|
||||||
|
Computer {
|
||||||
|
uid: DeviceUid::new(format!("device{}", n + 1)),
|
||||||
|
device_type: *device_type,
|
||||||
|
name: Ok(name.clone()),
|
||||||
|
root_folder_uid: root.clone(),
|
||||||
|
creation_time: self.current(root).map_or(0, |remote| remote.created),
|
||||||
|
last_sync_time: None,
|
||||||
|
share_id: ShareId::new(format!("device-share{}", n + 1)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn mint_uid(&mut self) -> NodeUid {
|
fn mint_uid(&mut self) -> NodeUid {
|
||||||
self.next_link += 1;
|
self.next_link += 1;
|
||||||
uid(&format!("L{}", self.next_link))
|
uid(&format!("L{}", self.next_link))
|
||||||
@@ -305,6 +328,7 @@ impl FakeDrive {
|
|||||||
events: Vec::new(),
|
events: Vec::new(),
|
||||||
block_pattern: vec![pdfs_core::cache::BLOCK_SIZE],
|
block_pattern: vec![pdfs_core::cache::BLOCK_SIZE],
|
||||||
max_space: 1 << 40,
|
max_space: 1 << 40,
|
||||||
|
devices: Vec::new(),
|
||||||
};
|
};
|
||||||
let now = now_secs();
|
let now = now_secs();
|
||||||
server.nodes.insert(
|
server.nodes.insert(
|
||||||
@@ -338,6 +362,8 @@ impl FakeDrive {
|
|||||||
hold: Mutex::new(None),
|
hold: Mutex::new(None),
|
||||||
hold_answer: Mutex::new(None),
|
hold_answer: Mutex::new(None),
|
||||||
hold_trash: Mutex::new(None),
|
hold_trash: Mutex::new(None),
|
||||||
|
hold_download: Mutex::new(None),
|
||||||
|
hold_any_landing: AtomicBool::new(false),
|
||||||
hold_landing: Mutex::new(None),
|
hold_landing: Mutex::new(None),
|
||||||
drop_after_create: AtomicBool::new(false),
|
drop_after_create: AtomicBool::new(false),
|
||||||
lose_create_reply: AtomicBool::new(false),
|
lose_create_reply: AtomicBool::new(false),
|
||||||
@@ -616,8 +642,13 @@ struct ClientInner {
|
|||||||
hold_answer: Mutex<Option<Arc<HoldState>>>,
|
hold_answer: Mutex<Option<Arc<HoldState>>>,
|
||||||
/// The trash [`FakeClient::hold_next_trash`] is waiting for.
|
/// The trash [`FakeClient::hold_next_trash`] is waiting for.
|
||||||
hold_trash: Mutex<Option<Arc<HoldState>>>,
|
hold_trash: Mutex<Option<Arc<HoldState>>>,
|
||||||
|
/// The download [`FakeClient::hold_next_download`] is waiting for.
|
||||||
|
hold_download: Mutex<Option<Arc<HoldState>>>,
|
||||||
/// The landing [`FakeClient::hold_next_landing`] is waiting for.
|
/// The landing [`FakeClient::hold_next_landing`] is waiting for.
|
||||||
hold_landing: Mutex<Option<Arc<HoldState>>>,
|
hold_landing: Mutex<Option<Arc<HoldState>>>,
|
||||||
|
/// Set by [`FakeClient::hold_next_landing_of_any`]: hold whichever node
|
||||||
|
/// lands next.
|
||||||
|
hold_any_landing: AtomicBool,
|
||||||
/// Set by [`FakeClient::drop_link_after_next_create`].
|
/// Set by [`FakeClient::drop_link_after_next_create`].
|
||||||
drop_after_create: AtomicBool,
|
drop_after_create: AtomicBool,
|
||||||
/// Set by [`FakeClient::lose_reply_to_next_create`].
|
/// Set by [`FakeClient::lose_reply_to_next_create`].
|
||||||
@@ -755,6 +786,18 @@ impl FakeClient {
|
|||||||
Held(state)
|
Held(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hold the next download of `uid`'s content once Drive has answered it,
|
||||||
|
/// before the bytes reach the daemon, until the answer is dropped.
|
||||||
|
pub(crate) fn hold_next_download(&self, uid: &NodeUid) -> Held {
|
||||||
|
let state = Arc::new(HoldState {
|
||||||
|
uid: uid.clone(),
|
||||||
|
reached: AtomicBool::new(false),
|
||||||
|
released: AtomicBool::new(false),
|
||||||
|
});
|
||||||
|
*self.0.hold_download.lock() = Some(state.clone());
|
||||||
|
Held(state)
|
||||||
|
}
|
||||||
|
|
||||||
/// Hold the drain once the next upload to `uid` has landed and its blob
|
/// Hold the drain once the next upload to `uid` has landed and its blob
|
||||||
/// is let go of, before the tree has the node (`DriveApi::landing`), until
|
/// is let go of, before the tree has the node (`DriveApi::landing`), until
|
||||||
/// the answer is dropped.
|
/// the answer is dropped.
|
||||||
@@ -768,6 +811,14 @@ impl FakeClient {
|
|||||||
Held(state)
|
Held(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`FakeClient::hold_next_landing`] for whichever node lands next, such
|
||||||
|
/// as a new file, whose uid Drive has not minted yet.
|
||||||
|
pub(crate) fn hold_next_landing_of_any(&self) -> Held {
|
||||||
|
let held = self.hold_next_landing(&uid("*"));
|
||||||
|
self.0.hold_any_landing.store(true, Ordering::SeqCst);
|
||||||
|
held
|
||||||
|
}
|
||||||
|
|
||||||
/// Make the next file create on Drive, then hold its answer until the
|
/// Make the next file create on Drive, then hold its answer until the
|
||||||
/// answer is dropped, as a slow link does.
|
/// answer is dropped, as a slow link does.
|
||||||
pub(crate) fn hold_reply_to_next_create(&self) -> Held {
|
pub(crate) fn hold_reply_to_next_create(&self) -> Held {
|
||||||
@@ -1396,6 +1447,8 @@ impl DriveApi for FakeClient {
|
|||||||
.ok_or_else(|| api(ResponseCode::DoesNotExist, "file not found"))
|
.ok_or_else(|| api(ResponseCode::DoesNotExist, "file not found"))
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
self.wait_if_held(&self.0.hold_download, std::slice::from_ref(uid))
|
||||||
|
.await;
|
||||||
output.write_all(&content).map_err(io_error)
|
output.write_all(&content).map_err(io_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1484,8 +1537,12 @@ impl DriveApi for FakeClient {
|
|||||||
fn landing(&self, uid: &NodeUid) {
|
fn landing(&self, uid: &NodeUid) {
|
||||||
let state = {
|
let state = {
|
||||||
let mut hold = self.0.hold_landing.lock();
|
let mut hold = self.0.hold_landing.lock();
|
||||||
|
let any = self.0.hold_any_landing.load(Ordering::SeqCst);
|
||||||
match hold.as_ref() {
|
match hold.as_ref() {
|
||||||
Some(state) if state.uid == *uid => hold.take(),
|
Some(state) if any || state.uid == *uid => {
|
||||||
|
self.0.hold_any_landing.store(false, Ordering::SeqCst);
|
||||||
|
hold.take()
|
||||||
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1506,6 +1563,42 @@ impl DriveApi for FakeClient {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn enumerate_devices(&self) -> Result<Vec<Computer>> {
|
||||||
|
self.request(false, |server| {
|
||||||
|
Ok((0..server.devices.len())
|
||||||
|
.map(|n| server.computer(n))
|
||||||
|
.collect())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_device(&self, name: &str, device_type: DeviceType) -> Result<Computer> {
|
||||||
|
self.request(true, |server| {
|
||||||
|
// A device root has no parent: like on Drive, it is not in My files.
|
||||||
|
// It is listed at once, as the My files root is: the listing lag is
|
||||||
|
// about children appearing, and the daemon lists the new root next.
|
||||||
|
let root = server.mint_uid();
|
||||||
|
let now = now_secs();
|
||||||
|
server.nodes.insert(
|
||||||
|
root.clone(),
|
||||||
|
vec![(
|
||||||
|
Instant::now() - HISTORY,
|
||||||
|
Some(Remote {
|
||||||
|
parent: None,
|
||||||
|
name: name.to_owned(),
|
||||||
|
trashed: false,
|
||||||
|
created: now,
|
||||||
|
modified: now,
|
||||||
|
file: None,
|
||||||
|
}),
|
||||||
|
)],
|
||||||
|
);
|
||||||
|
server.devices.push((name.to_owned(), device_type, root));
|
||||||
|
Ok(server.computer(server.devices.len() - 1))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
async fn enumerate_trash_node_uids(&self) -> Result<Vec<NodeUid>> {
|
async fn enumerate_trash_node_uids(&self) -> Result<Vec<NodeUid>> {
|
||||||
self.request(false, |server| {
|
self.request(false, |server| {
|
||||||
Ok(server
|
Ok(server
|
||||||
|
|||||||
@@ -538,6 +538,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
reload_pending: Cell::new(false),
|
reload_pending: Cell::new(false),
|
||||||
loading: Cell::new(false),
|
loading: Cell::new(false),
|
||||||
has_more: Cell::new(false),
|
has_more: Cell::new(false),
|
||||||
|
library: Cell::new(true),
|
||||||
page_waiters: RefCell::new(Vec::new()),
|
page_waiters: RefCell::new(Vec::new()),
|
||||||
width: Cell::new(0),
|
width: Cell::new(0),
|
||||||
photo_tex: RefCell::new(HashMap::new()),
|
photo_tex: RefCell::new(HashMap::new()),
|
||||||
@@ -580,6 +581,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
pause: locations_widgets.pause.clone(),
|
pause: locations_widgets.pause.clone(),
|
||||||
paused: Cell::new(false),
|
paused: Cell::new(false),
|
||||||
sign_in: locations_widgets.card_sign_in.clone(),
|
sign_in: locations_widgets.card_sign_in.clone(),
|
||||||
|
restart: locations_widgets.card_restart.clone(),
|
||||||
},
|
},
|
||||||
queue: QueueState {
|
queue: QueueState {
|
||||||
group: locations_widgets.queue_group.clone(),
|
group: locations_widgets.queue_group.clone(),
|
||||||
|
|||||||
@@ -520,9 +520,9 @@ pub(crate) fn clear_selection(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Reflect the current selection in the bulk bar: how many are selected, and
|
/// Reflect the current selection in the bulk bar: how many are selected, and
|
||||||
/// which bulk actions apply to that mix. Offline state is a file-only notion, so
|
/// which bulk actions apply to that mix. An entry kept offline through a pinned
|
||||||
/// a folder anywhere in the selection disables the two pin buttons rather than
|
/// folder above it cannot be made online only on its own, so it does not count
|
||||||
/// half-failing once pressed.
|
/// towards Make online only.
|
||||||
pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
|
pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
|
||||||
let entries = selected_entries(ui);
|
let entries = selected_entries(ui);
|
||||||
// One selected entry is the details pane's job; the bar is for a batch.
|
// One selected entry is the details pane's job; the bar is for a batch.
|
||||||
@@ -530,8 +530,7 @@ pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
|
|||||||
ui.browser.bulk.set_reveal_child(false);
|
ui.browser.bulk.set_reveal_child(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let files_only = entries.iter().all(|e| !e.is_dir);
|
let any_pinned = entries.iter().any(|e| e.pinned && !e.pinned_by_folder);
|
||||||
let any_pinned = entries.iter().any(|e| e.pinned);
|
|
||||||
let any_unpinned = entries.iter().any(|e| !e.pinned);
|
let any_unpinned = entries.iter().any(|e| !e.pinned);
|
||||||
let mounted = *ui.mounted.borrow();
|
let mounted = *ui.mounted.borrow();
|
||||||
ui.browser.bulk_label.set_label(&ngettext_f(
|
ui.browser.bulk_label.set_label(&ngettext_f(
|
||||||
@@ -541,12 +540,8 @@ pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
|
|||||||
&[],
|
&[],
|
||||||
));
|
));
|
||||||
ui.browser.bulk_trash.set_sensitive(mounted);
|
ui.browser.bulk_trash.set_sensitive(mounted);
|
||||||
ui.browser
|
ui.browser.bulk_pin.set_sensitive(mounted && any_unpinned);
|
||||||
.bulk_pin
|
ui.browser.bulk_unpin.set_sensitive(mounted && any_pinned);
|
||||||
.set_sensitive(mounted && files_only && any_unpinned);
|
|
||||||
ui.browser
|
|
||||||
.bulk_unpin
|
|
||||||
.set_sensitive(mounted && files_only && any_pinned);
|
|
||||||
ui.browser.bulk.set_reveal_child(true);
|
ui.browser.bulk.set_reveal_child(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,16 +647,19 @@ pub(crate) fn restore_uids(ui: &Rc<Ui>, uids: Vec<String>, count: usize) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pin or unpin every selected file, one request at a time.
|
/// Pin or unpin every selected entry, one request at a time. A folder's files
|
||||||
|
/// download in the background, so its request returns before they are there.
|
||||||
pub(crate) fn run_bulk_pin(ui: &Rc<Ui>, entries: Vec<DirEntry>, pin: bool) {
|
pub(crate) fn run_bulk_pin(ui: &Rc<Ui>, entries: Vec<DirEntry>, pin: bool) {
|
||||||
let socket = ui.dirs.control_socket();
|
let socket = ui.dirs.control_socket();
|
||||||
// Only the entries that need changing: re-pinning an already-pinned file is
|
// Only the entries that need changing: re-pinning an already-pinned file is
|
||||||
// a wasted round-trip, and it would inflate the count the toast reports.
|
// a wasted round-trip, and it would inflate the count the toast reports. One
|
||||||
let paths: Vec<String> = entries
|
// kept through a pinned folder above it has no pin of its own to remove.
|
||||||
|
let changing: Vec<&DirEntry> = entries
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|e| !e.is_dir && e.pinned != pin)
|
.filter(|e| !e.pinned_by_folder && e.pinned != pin)
|
||||||
.map(|e| entry_rel(ui, e))
|
|
||||||
.collect();
|
.collect();
|
||||||
|
let any_folder = changing.iter().any(|e| e.is_dir);
|
||||||
|
let paths: Vec<String> = changing.iter().map(|e| entry_rel(ui, e)).collect();
|
||||||
if paths.is_empty() {
|
if paths.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -698,13 +696,27 @@ pub(crate) fn run_bulk_pin(ui: &Rc<Ui>, entries: Vec<DirEntry>, pin: bool) {
|
|||||||
(0, None) => {}
|
(0, None) => {}
|
||||||
(n, _) => toast(
|
(n, _) => toast(
|
||||||
&ui,
|
&ui,
|
||||||
&if pin {
|
&if pin && any_folder {
|
||||||
|
ngettext_f(
|
||||||
|
"Making {n} item available offline…",
|
||||||
|
"Making {n} items available offline…",
|
||||||
|
n as u64,
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
} else if pin {
|
||||||
ngettext_f(
|
ngettext_f(
|
||||||
"{n} file is now available offline",
|
"{n} file is now available offline",
|
||||||
"{n} files are now available offline",
|
"{n} files are now available offline",
|
||||||
n as u64,
|
n as u64,
|
||||||
&[],
|
&[],
|
||||||
)
|
)
|
||||||
|
} else if any_folder {
|
||||||
|
ngettext_f(
|
||||||
|
"{n} item is now online only",
|
||||||
|
"{n} items are now online only",
|
||||||
|
n as u64,
|
||||||
|
&[],
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
ngettext_f(
|
ngettext_f(
|
||||||
"{n} file is now online only",
|
"{n} file is now online only",
|
||||||
@@ -860,13 +872,17 @@ pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
|||||||
}
|
}
|
||||||
menu.section();
|
menu.section();
|
||||||
|
|
||||||
if !entry.is_dir {
|
// Kept through a pinned folder above it, the entry has no pin of its own
|
||||||
|
// to remove: the check shows that state without offering to change it.
|
||||||
|
if entry.pinned_by_folder {
|
||||||
|
menu.locked_toggle(&gettext("Available offline"), true);
|
||||||
|
} else {
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
menu.toggle(&gettext("Available offline"), entry.pinned, move |_| {
|
menu.toggle(&gettext("Available offline"), entry.pinned, move |_| {
|
||||||
toggle_pin(&ui_c, &entry_c)
|
toggle_pin(&ui_c, &entry_c)
|
||||||
});
|
});
|
||||||
menu.section();
|
|
||||||
}
|
}
|
||||||
|
menu.section();
|
||||||
|
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
menu.item(&gettext("Share…"), move || {
|
menu.item(&gettext("Share…"), move || {
|
||||||
@@ -908,9 +924,8 @@ pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
|||||||
/// simply absent here rather than offered and then refused.
|
/// simply absent here rather than offered and then refused.
|
||||||
pub(crate) fn bulk_context_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu {
|
pub(crate) fn bulk_context_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu {
|
||||||
let mut menu = ActionMenu::new();
|
let mut menu = ActionMenu::new();
|
||||||
// Offline state is a file-only notion; the check shows whether the whole
|
// The check shows whether the whole batch already is offline.
|
||||||
// batch already is.
|
{
|
||||||
if entries.iter().all(|e| !e.is_dir) {
|
|
||||||
let (ui_c, batch) = (ui.clone(), entries.clone());
|
let (ui_c, batch) = (ui.clone(), entries.clone());
|
||||||
let all_pinned = entries.iter().all(|e| e.pinned);
|
let all_pinned = entries.iter().all(|e| e.pinned);
|
||||||
menu.toggle(&gettext("Available offline"), all_pinned, move |pin| {
|
menu.toggle(&gettext("Available offline"), all_pinned, move |pin| {
|
||||||
@@ -1147,6 +1162,7 @@ pub(crate) fn toggle_pin(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
let rx = spawn_request(ui.dirs.control_socket(), req);
|
let rx = spawn_request(ui.dirs.control_socket(), req);
|
||||||
let name = entry.name.clone();
|
let name = entry.name.clone();
|
||||||
let pinned = entry.pinned;
|
let pinned = entry.pinned;
|
||||||
|
let is_dir = entry.is_dir;
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
match rx.recv().await {
|
match rx.recv().await {
|
||||||
@@ -1166,6 +1182,9 @@ pub(crate) fn toggle_pin(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
&ui,
|
&ui,
|
||||||
&if pinned {
|
&if pinned {
|
||||||
gettext_f("“{name}” is now online only", &[("name", &name)])
|
gettext_f("“{name}” is now online only", &[("name", &name)])
|
||||||
|
} else if is_dir {
|
||||||
|
// The daemon downloads a folder's files in the background.
|
||||||
|
gettext_f("Making “{name}” available offline…", &[("name", &name)])
|
||||||
} else {
|
} else {
|
||||||
gettext_f("“{name}” is now available offline", &[("name", &name)])
|
gettext_f("“{name}” is now available offline", &[("name", &name)])
|
||||||
},
|
},
|
||||||
@@ -2487,16 +2506,19 @@ pub(crate) fn start_upload(ui: &Rc<Ui>, sources: Vec<String>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The sync-state badge for an entry: `(icon, css-class)`, or `None` for folders
|
/// The sync-state badge for an entry: `(icon, css-class)`. A folder carries no
|
||||||
/// (which carry no per-file cache state). A sync issue ranks first, on folders
|
/// cache state of its own, so it has one only when kept offline. A sync issue
|
||||||
/// too: the change waits for the user. Pinned (kept offline) ranks above merely
|
/// ranks first, on folders too: the change waits for the user. Pinned (kept
|
||||||
/// cached (downloaded, evictable); everything else is online-only.
|
/// offline) ranks above merely cached (downloaded, evictable); everything else
|
||||||
|
/// is online-only.
|
||||||
pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)> {
|
pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)> {
|
||||||
if entry.issue.is_some() {
|
if entry.issue.is_some() {
|
||||||
return Some(("dialog-warning-symbolic", "badge-issue"));
|
return Some(("dialog-warning-symbolic", "badge-issue"));
|
||||||
}
|
}
|
||||||
if entry.is_dir {
|
if entry.is_dir {
|
||||||
return None;
|
return entry
|
||||||
|
.pinned
|
||||||
|
.then_some(("pdfs-offline-symbolic", "badge-pinned"));
|
||||||
}
|
}
|
||||||
Some(if entry.pinned {
|
Some(if entry.pinned {
|
||||||
("pdfs-offline-symbolic", "badge-pinned")
|
("pdfs-offline-symbolic", "badge-pinned")
|
||||||
@@ -2905,6 +2927,7 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
|||||||
size: h.size,
|
size: h.size,
|
||||||
modified: h.modified,
|
modified: h.modified,
|
||||||
pinned: h.pinned,
|
pinned: h.pinned,
|
||||||
|
pinned_by_folder: false,
|
||||||
cached: h.cached,
|
cached: h.cached,
|
||||||
uid: h.uid.clone(),
|
uid: h.uid.clone(),
|
||||||
path: h.path.clone(),
|
path: h.path.clone(),
|
||||||
@@ -2965,6 +2988,7 @@ mod tests {
|
|||||||
size,
|
size,
|
||||||
modified,
|
modified,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
|
pinned_by_folder: false,
|
||||||
cached: false,
|
cached: false,
|
||||||
uid: String::new(),
|
uid: String::new(),
|
||||||
path: String::new(),
|
path: String::new(),
|
||||||
@@ -2992,6 +3016,8 @@ mod tests {
|
|||||||
|
|
||||||
let mut folder = entry("d", true, 0, 0);
|
let mut folder = entry("d", true, 0, 0);
|
||||||
assert_eq!(badge_for(&folder), None);
|
assert_eq!(badge_for(&folder), None);
|
||||||
|
folder.pinned = true;
|
||||||
|
assert_eq!(badge_for(&folder).unwrap().1, "badge-pinned");
|
||||||
folder.issue = Some(SyncIssue::Missing);
|
folder.issue = Some(SyncIssue::Missing);
|
||||||
assert_eq!(badge_for(&folder).unwrap().1, "badge-issue");
|
assert_eq!(badge_for(&folder).unwrap().1, "badge-issue");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ pub(crate) struct SyncCard {
|
|||||||
pub(crate) paused: Cell<bool>,
|
pub(crate) paused: Cell<bool>,
|
||||||
/// Shown only while Proton refuses the session; opens the sign-in page.
|
/// Shown only while Proton refuses the session; opens the sign-in page.
|
||||||
pub(crate) sign_in: gtk4::Button,
|
pub(crate) sign_in: gtk4::Button,
|
||||||
|
/// Shown only while the daemon is older than the app; restarts it.
|
||||||
|
pub(crate) restart: gtk4::Button,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a folder row's controls are built from. The subtitle and progress bar
|
/// What a folder row's controls are built from. The subtitle and progress bar
|
||||||
@@ -111,6 +113,7 @@ pub(crate) struct LocationsWidgets {
|
|||||||
pub(crate) card_title: gtk4::Label,
|
pub(crate) card_title: gtk4::Label,
|
||||||
pub(crate) card_detail: gtk4::Label,
|
pub(crate) card_detail: gtk4::Label,
|
||||||
pub(crate) card_sign_in: gtk4::Button,
|
pub(crate) card_sign_in: gtk4::Button,
|
||||||
|
pub(crate) card_restart: gtk4::Button,
|
||||||
pub(crate) pause: adw::SplitButton,
|
pub(crate) pause: adw::SplitButton,
|
||||||
pub(crate) queue_group: adw::PreferencesGroup,
|
pub(crate) queue_group: adw::PreferencesGroup,
|
||||||
pub(crate) retry_all: gtk4::Button,
|
pub(crate) retry_all: gtk4::Button,
|
||||||
@@ -137,7 +140,7 @@ pub(crate) fn build_locations_page(history: >k4::Widget) -> (gtk4::Widget, Loc
|
|||||||
.build();
|
.build();
|
||||||
add_folder.add_css_class("flat");
|
add_folder.add_css_class("flat");
|
||||||
let transfers_group = build_transfers_group();
|
let transfers_group = build_transfers_group();
|
||||||
let (card, card_icon, card_title, card_detail, card_sign_in) = build_sync_card();
|
let (card, card_icon, card_title, card_detail, card_sign_in, card_restart) = build_sync_card();
|
||||||
let pause = build_pause_button();
|
let pause = build_pause_button();
|
||||||
let (queue_group, retry_all) = build_queue_group();
|
let (queue_group, retry_all) = build_queue_group();
|
||||||
let conflicts_group = adw::PreferencesGroup::builder()
|
let conflicts_group = adw::PreferencesGroup::builder()
|
||||||
@@ -257,6 +260,7 @@ pub(crate) fn build_locations_page(history: >k4::Widget) -> (gtk4::Widget, Loc
|
|||||||
card_title,
|
card_title,
|
||||||
card_detail,
|
card_detail,
|
||||||
card_sign_in,
|
card_sign_in,
|
||||||
|
card_restart,
|
||||||
pause,
|
pause,
|
||||||
queue_group,
|
queue_group,
|
||||||
retry_all,
|
retry_all,
|
||||||
@@ -299,6 +303,11 @@ pub(crate) fn wire_locations(ui: &Rc<Ui>, retry: >k4::Button, add_folder: >k
|
|||||||
.card
|
.card
|
||||||
.sign_in
|
.sign_in
|
||||||
.connect_clicked(move |_| show_login(&ui_sign_in));
|
.connect_clicked(move |_| show_login(&ui_sign_in));
|
||||||
|
let ui_restart = ui.clone();
|
||||||
|
ui.locations
|
||||||
|
.card
|
||||||
|
.restart
|
||||||
|
.connect_clicked(move |_| restart_service_then(&ui_restart, |_| {}));
|
||||||
let ui_retry = ui.clone();
|
let ui_retry = ui.clone();
|
||||||
ui.locations
|
ui.locations
|
||||||
.queue
|
.queue
|
||||||
@@ -396,6 +405,7 @@ fn build_sync_card() -> (
|
|||||||
gtk4::Label,
|
gtk4::Label,
|
||||||
gtk4::Label,
|
gtk4::Label,
|
||||||
gtk4::Button,
|
gtk4::Button,
|
||||||
|
gtk4::Button,
|
||||||
) {
|
) {
|
||||||
let icon = gtk4::Image::builder()
|
let icon = gtk4::Image::builder()
|
||||||
.icon_name("pdfs-sync-symbolic")
|
.icon_name("pdfs-sync-symbolic")
|
||||||
@@ -430,10 +440,18 @@ fn build_sync_card() -> (
|
|||||||
.build();
|
.build();
|
||||||
sign_in.add_css_class("pill");
|
sign_in.add_css_class("pill");
|
||||||
sign_in.add_css_class("suggested-action");
|
sign_in.add_css_class("suggested-action");
|
||||||
|
let restart = gtk4::Button::builder()
|
||||||
|
.label(gettext("Restart"))
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
restart.add_css_class("pill");
|
||||||
|
restart.add_css_class("suggested-action");
|
||||||
card.append(&icon);
|
card.append(&icon);
|
||||||
card.append(&text);
|
card.append(&text);
|
||||||
card.append(&sign_in);
|
card.append(&sign_in);
|
||||||
(card, icon, title, detail, sign_in)
|
card.append(&restart);
|
||||||
|
(card, icon, title, detail, sign_in, restart)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pause/Resume, with timed pauses in its menu.
|
/// Pause/Resume, with timed pauses in its menu.
|
||||||
|
|||||||
@@ -227,6 +227,11 @@ pub(crate) fn login_error_message(error: &pdfs_core::Error) -> String {
|
|||||||
pdfs_core::Error::Proton(ProtonError::Api(api)) if api.http_status == 429 => {
|
pdfs_core::Error::Proton(ProtonError::Api(api)) if api.http_status == 429 => {
|
||||||
gettext("Too many sign-in attempts. Wait a few minutes and try again.")
|
gettext("Too many sign-in attempts. Wait a few minutes and try again.")
|
||||||
}
|
}
|
||||||
|
// Proton's text for this one asks the user to update an app that is
|
||||||
|
// already current.
|
||||||
|
pdfs_core::Error::Proton(e) if pdfs_core::error::sign_in_blocked(e) => gettext(
|
||||||
|
"Proton is temporarily refusing sign-in from this device. Your app version is not the problem. Wait several hours, up to a day, without trying again, then sign in.",
|
||||||
|
),
|
||||||
pdfs_core::Error::Proton(ProtonError::Api(api)) if !api.message.is_empty() => {
|
pdfs_core::Error::Proton(ProtonError::Api(api)) if !api.message.is_empty() => {
|
||||||
api.message.clone()
|
api.message.clone()
|
||||||
}
|
}
|
||||||
@@ -628,6 +633,21 @@ mod tests {
|
|||||||
assert!(login_error_message(&error).contains("no mailbox password"));
|
assert!(login_error_message(&error).contains("no mailbox password"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_blocked_sign_in_does_not_ask_for_an_update() {
|
||||||
|
use pdfs_core::proton_sdk::api::ResponseCode;
|
||||||
|
use pdfs_core::proton_sdk::{ProtonApiError, ProtonError};
|
||||||
|
let error = pdfs_core::Error::Proton(ProtonError::Api(ProtonApiError {
|
||||||
|
code: ResponseCode::Blocked,
|
||||||
|
http_status: 422,
|
||||||
|
message: "This version of the app is no longer supported, please update to continue using the app".into(),
|
||||||
|
details: None,
|
||||||
|
}));
|
||||||
|
let message = login_error_message(&error);
|
||||||
|
assert!(message.contains("temporarily refusing"), "{message}");
|
||||||
|
assert!(!message.contains("update"), "{message}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unmapped_error_keeps_its_text() {
|
fn an_unmapped_error_keeps_its_text() {
|
||||||
let error = pdfs_core::Error::Other("something odd".into());
|
let error = pdfs_core::Error::Other("something odd".into());
|
||||||
|
|||||||
@@ -140,6 +140,9 @@ pub(crate) struct GalleryState {
|
|||||||
pub(crate) loading: Cell<bool>,
|
pub(crate) loading: Cell<bool>,
|
||||||
/// Whether the last page came back full, so there may be more to load.
|
/// Whether the last page came back full, so there may be more to load.
|
||||||
pub(crate) has_more: Cell<bool>,
|
pub(crate) has_more: Cell<bool>,
|
||||||
|
/// Whether the account has a photo library. Until the daemon says it has
|
||||||
|
/// none, assume it does, so Upload is not greyed out while the page loads.
|
||||||
|
pub(crate) library: Cell<bool>,
|
||||||
/// Run once the page in flight has landed in [`Self::model`] — how the
|
/// Run once the page in flight has landed in [`Self::model`] — how the
|
||||||
/// lightbox steps past the last loaded photo.
|
/// lightbox steps past the last loaded photo.
|
||||||
pub(crate) page_waiters: RefCell<Vec<Box<dyn FnOnce()>>>,
|
pub(crate) page_waiters: RefCell<Vec<Box<dyn FnOnce()>>>,
|
||||||
@@ -3341,6 +3344,12 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
|||||||
items,
|
items,
|
||||||
counts,
|
counts,
|
||||||
})) => {
|
})) => {
|
||||||
|
// Nothing can go into a library the account does not have; the
|
||||||
|
// SDK refuses the upload before sending it (issue #38).
|
||||||
|
ui.gallery.library.set(available);
|
||||||
|
ui.gallery
|
||||||
|
.upload
|
||||||
|
.set_sensitive(available && *ui.mounted.borrow());
|
||||||
if !available {
|
if !available {
|
||||||
gallery_status(
|
gallery_status(
|
||||||
&ui,
|
&ui,
|
||||||
|
|||||||
@@ -622,6 +622,7 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
|||||||
size: 0,
|
size: 0,
|
||||||
modified: item.modified,
|
modified: item.modified,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
|
pinned_by_folder: false,
|
||||||
cached: false,
|
cached: false,
|
||||||
uid: item.uid.clone(),
|
uid: item.uid.clone(),
|
||||||
path: item.path.clone(),
|
path: item.path.clone(),
|
||||||
|
|||||||
@@ -958,7 +958,9 @@ fn quota_display(max_space: i64, used_space: i64) -> (f64, String) {
|
|||||||
pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) {
|
pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) {
|
||||||
*ui.mounted.borrow_mut() = mounted;
|
*ui.mounted.borrow_mut() = mounted;
|
||||||
sync_mounted_actions(ui, mounted);
|
sync_mounted_actions(ui, mounted);
|
||||||
ui.gallery.upload.set_sensitive(mounted);
|
ui.gallery
|
||||||
|
.upload
|
||||||
|
.set_sensitive(mounted && ui.gallery.library.get());
|
||||||
ui.details.details.pin_row.set_sensitive(mounted);
|
ui.details.details.pin_row.set_sensitive(mounted);
|
||||||
ui.details.details.rename_button.set_sensitive(mounted);
|
ui.details.details.rename_button.set_sensitive(mounted);
|
||||||
ui.details.details.trash_button.set_sensitive(mounted);
|
ui.details.details.trash_button.set_sensitive(mounted);
|
||||||
@@ -1230,6 +1232,7 @@ pub(crate) fn paint_status(ui: &Rc<Ui>, status: Response) {
|
|||||||
paused_until,
|
paused_until,
|
||||||
session_expired,
|
session_expired,
|
||||||
tokens_unsaved,
|
tokens_unsaved,
|
||||||
|
version,
|
||||||
..
|
..
|
||||||
} = status
|
} = status
|
||||||
else {
|
else {
|
||||||
@@ -1244,6 +1247,8 @@ pub(crate) fn paint_status(ui: &Rc<Ui>, status: Response) {
|
|||||||
SyncState::SessionExpired { queued }
|
SyncState::SessionExpired { queued }
|
||||||
} else if tokens_unsaved {
|
} else if tokens_unsaved {
|
||||||
SyncState::TokensUnsaved
|
SyncState::TokensUnsaved
|
||||||
|
} else if pdfs_core::control::daemon_outdated(&version) {
|
||||||
|
SyncState::Outdated { running: version }
|
||||||
} else if paused {
|
} else if paused {
|
||||||
SyncState::Paused {
|
SyncState::Paused {
|
||||||
until: paused_until,
|
until: paused_until,
|
||||||
@@ -1315,6 +1320,12 @@ pub(crate) enum SyncState {
|
|||||||
/// The keyring refuses the session's refreshed tokens. Sync goes on, but
|
/// The keyring refuses the session's refreshed tokens. Sync goes on, but
|
||||||
/// a reboot before the keyring takes them signs the user out.
|
/// a reboot before the keyring takes them signs the user out.
|
||||||
TokensUnsaved,
|
TokensUnsaved,
|
||||||
|
/// The daemon is an older release than this window: an upgrade replaced
|
||||||
|
/// the files but left the service running, with the old release's bugs,
|
||||||
|
/// until it restarts (#33). `running` is empty when it is too old to say.
|
||||||
|
Outdated {
|
||||||
|
running: String,
|
||||||
|
},
|
||||||
/// The user paused syncing; nothing goes up until it resumes. Outranks
|
/// The user paused syncing; nothing goes up until it resumes. Outranks
|
||||||
/// everything else, because it is the reason for everything else.
|
/// everything else, because it is the reason for everything else.
|
||||||
Paused {
|
Paused {
|
||||||
@@ -1350,6 +1361,7 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
|||||||
let paused = matches!(state, SyncState::Paused { .. });
|
let paused = matches!(state, SyncState::Paused { .. });
|
||||||
let connected = !matches!(state, SyncState::Disconnected | SyncState::NotResponding);
|
let connected = !matches!(state, SyncState::Disconnected | SyncState::NotResponding);
|
||||||
let expired = matches!(state, SyncState::SessionExpired { .. });
|
let expired = matches!(state, SyncState::SessionExpired { .. });
|
||||||
|
let outdated = matches!(state, SyncState::Outdated { .. });
|
||||||
let (icon, class, title, detail) = match state {
|
let (icon, class, title, detail) = match state {
|
||||||
SyncState::SessionExpired { queued } => (
|
SyncState::SessionExpired { queued } => (
|
||||||
"dialog-password-symbolic",
|
"dialog-password-symbolic",
|
||||||
@@ -1372,6 +1384,20 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
|||||||
"Unlock your keyring, or the next reboot signs you out",
|
"Unlock your keyring, or the next reboot signs you out",
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
|
SyncState::Outdated { running } => (
|
||||||
|
"dialog-warning-symbolic",
|
||||||
|
Some("warning"),
|
||||||
|
gettext("Restart to finish updating"),
|
||||||
|
Some(if running.is_empty() {
|
||||||
|
gettext("An older version of Proton Drive is still running")
|
||||||
|
} else {
|
||||||
|
// Translators: {version} is a release number such as "3.2.0".
|
||||||
|
gettext_f(
|
||||||
|
"Proton Drive {version} is still running",
|
||||||
|
&[("version", &running)],
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
SyncState::Paused { until, queued } => (
|
SyncState::Paused { until, queued } => (
|
||||||
"media-playback-pause-symbolic",
|
"media-playback-pause-symbolic",
|
||||||
Some("warning"),
|
Some("warning"),
|
||||||
@@ -1443,6 +1469,7 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
|||||||
image.add_css_class(class);
|
image.add_css_class(class);
|
||||||
}
|
}
|
||||||
ui.locations.card.sign_in.set_visible(expired);
|
ui.locations.card.sign_in.set_visible(expired);
|
||||||
|
ui.locations.card.restart.set_visible(outdated);
|
||||||
ui.status.status_title.set_label(&title);
|
ui.status.status_title.set_label(&title);
|
||||||
ui.status.status_detail.set_visible(detail.is_some());
|
ui.status.status_detail.set_visible(detail.is_some());
|
||||||
ui.status
|
ui.status
|
||||||
@@ -1511,7 +1538,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
|||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(gettext("No files kept offline"))
|
.title(gettext("No files kept offline"))
|
||||||
.subtitle(gettext(
|
.subtitle(gettext(
|
||||||
"Right-click a file and choose “Make available offline”.",
|
"Right-click a file or folder and choose “Available offline”.",
|
||||||
))
|
))
|
||||||
.build();
|
.build();
|
||||||
ui.status.pins_group.add(&row);
|
ui.status.pins_group.add(&row);
|
||||||
|
|||||||
@@ -524,6 +524,7 @@ mod tests {
|
|||||||
size: 0,
|
size: 0,
|
||||||
modified,
|
modified,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
|
pinned_by_folder: false,
|
||||||
cached: false,
|
cached: false,
|
||||||
uid: name.into(),
|
uid: name.into(),
|
||||||
path: String::new(),
|
path: String::new(),
|
||||||
|
|||||||
@@ -384,11 +384,20 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
} else {
|
} else {
|
||||||
parent.to_string()
|
parent.to_string()
|
||||||
});
|
});
|
||||||
// Pinning is per file: the context menu and the selection bar offer it for
|
// Kept through a pinned folder above it, the entry has no pin of its own
|
||||||
// files only, so the pane must not offer it for folders either.
|
// to remove, so the switch shows that state without offering to change it.
|
||||||
d.pin_row.set_active(entry.pinned);
|
d.pin_row.set_active(entry.pinned);
|
||||||
d.pin_row.set_visible(!entry.is_dir);
|
d.pin_row.set_subtitle(&if entry.pinned_by_folder {
|
||||||
d.pin_row.set_sensitive(*ui.mounted.borrow());
|
gettext("Kept on this computer because a folder it is in is available offline.")
|
||||||
|
} else if entry.is_dir {
|
||||||
|
gettext(
|
||||||
|
"Keep a copy of everything in this folder on this computer, even without a connection.",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
gettext("Keep a copy on this computer, even without a connection.")
|
||||||
|
});
|
||||||
|
d.pin_row
|
||||||
|
.set_sensitive(*ui.mounted.borrow() && !entry.pinned_by_folder);
|
||||||
d.open_button.set_label(&if entry.is_dir {
|
d.open_button.set_label(&if entry.is_dir {
|
||||||
gettext("Open folder")
|
gettext("Open folder")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -61,6 +61,18 @@ impl ActionMenu {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A greyed-out check item showing `active`, for a state the user cannot
|
||||||
|
/// change from here.
|
||||||
|
pub(crate) fn locked_toggle(&mut self, label: &str, active: bool) -> &mut Self {
|
||||||
|
let name = self.next_action();
|
||||||
|
let action = gio::SimpleAction::new_stateful(&name, None, &active.to_variant());
|
||||||
|
action.set_enabled(false);
|
||||||
|
self.group.add_action(&action);
|
||||||
|
self.section
|
||||||
|
.append(Some(label), Some(&format!("menu.{name}")));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Close the current section; the next item starts a new one below a
|
/// Close the current section; the next item starts a new one below a
|
||||||
/// separator. An empty section is skipped, so callers can end a group of
|
/// separator. An empty section is skipped, so callers can end a group of
|
||||||
/// conditional items unconditionally.
|
/// conditional items unconditionally.
|
||||||
|
|||||||
@@ -77,13 +77,16 @@ pub(crate) fn icon_for(name: &str, is_dir: bool) -> gio::Icon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The sync badge of a Drive file, as in the app's browser: pinned, cached,
|
/// The sync badge of a Drive file, as in the app's browser: pinned, cached,
|
||||||
/// or online only. Folders and local files have none.
|
/// or online only. A folder has one only when kept offline; local files have
|
||||||
|
/// none.
|
||||||
pub(crate) fn badge_for(hit: &Hit) -> Option<(&'static str, &'static str)> {
|
pub(crate) fn badge_for(hit: &Hit) -> Option<(&'static str, &'static str)> {
|
||||||
let Hit::Drive(drive) = hit else {
|
let Hit::Drive(drive) = hit else {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
if drive.is_dir {
|
if drive.is_dir {
|
||||||
return None;
|
return drive
|
||||||
|
.pinned
|
||||||
|
.then_some(("pdfs-offline-symbolic", "badge-pinned"));
|
||||||
}
|
}
|
||||||
Some(if drive.pinned {
|
Some(if drive.pinned {
|
||||||
("pdfs-offline-symbolic", "badge-pinned")
|
("pdfs-offline-symbolic", "badge-pinned")
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ use ksni::{MenuItem, Status, ToolTip, Tray};
|
|||||||
use pdfs_core::auth;
|
use pdfs_core::auth;
|
||||||
use pdfs_core::config::AppDirs;
|
use pdfs_core::config::AppDirs;
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
Event, Feed, JobItem, Request, Response, TransferDirection, TransferItem, follow, send,
|
Event, Feed, JobItem, Request, Response, TransferDirection, TransferItem, daemon_outdated,
|
||||||
|
follow, send,
|
||||||
};
|
};
|
||||||
use pdfs_core::service;
|
use pdfs_core::service;
|
||||||
|
|
||||||
@@ -86,6 +87,9 @@ struct DriveState {
|
|||||||
paused: bool,
|
paused: bool,
|
||||||
/// Queued changes that keep failing, for the "N issues" item.
|
/// Queued changes that keep failing, for the "N issues" item.
|
||||||
failing: u64,
|
failing: u64,
|
||||||
|
/// Whether the daemon is an older release than the tray, left running by
|
||||||
|
/// an upgrade; offers the restart that finishes it.
|
||||||
|
outdated: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Summarise a work snapshot into one menu line, or empty when idle. A single
|
/// Summarise a work snapshot into one menu line, or empty when idle. A single
|
||||||
@@ -166,6 +170,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
|||||||
sync: String::new(),
|
sync: String::new(),
|
||||||
paused: false,
|
paused: false,
|
||||||
failing: 0,
|
failing: 0,
|
||||||
|
outdated: false,
|
||||||
},
|
},
|
||||||
// No daemon: describe login state instead so the menu is still useful.
|
// No daemon: describe login state instead so the menu is still useful.
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -202,6 +207,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
|||||||
sync: String::new(),
|
sync: String::new(),
|
||||||
paused: false,
|
paused: false,
|
||||||
failing: 0,
|
failing: 0,
|
||||||
|
outdated: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,6 +224,7 @@ fn unexpected_reply(default_mountpoint: &Path) -> DriveState {
|
|||||||
sync: String::new(),
|
sync: String::new(),
|
||||||
paused: false,
|
paused: false,
|
||||||
failing: 0,
|
failing: 0,
|
||||||
|
outdated: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +246,7 @@ fn status_state(
|
|||||||
paused,
|
paused,
|
||||||
session_expired,
|
session_expired,
|
||||||
tokens_unsaved,
|
tokens_unsaved,
|
||||||
|
version,
|
||||||
..
|
..
|
||||||
} = status
|
} = status
|
||||||
else {
|
else {
|
||||||
@@ -246,9 +254,10 @@ fn status_state(
|
|||||||
};
|
};
|
||||||
let (pinned, online, failing_ops, paused) = (*pinned, *online, *failing_ops, *paused);
|
let (pinned, online, failing_ops, paused) = (*pinned, *online, *failing_ops, *paused);
|
||||||
let queued = i18n::pending_summary(*pending_uploads, *pending_changes);
|
let queued = i18n::pending_summary(*pending_uploads, *pending_changes);
|
||||||
|
let outdated = daemon_outdated(version);
|
||||||
let phase = if *session_expired {
|
let phase = if *session_expired {
|
||||||
Phase::SessionExpired
|
Phase::SessionExpired
|
||||||
} else if *tokens_unsaved {
|
} else if *tokens_unsaved || outdated {
|
||||||
Phase::Attention
|
Phase::Attention
|
||||||
} else {
|
} else {
|
||||||
phase_of(
|
phase_of(
|
||||||
@@ -265,6 +274,9 @@ fn status_state(
|
|||||||
_ if *session_expired => gettext("Session expired — sign in again"),
|
_ if *session_expired => gettext("Session expired — sign in again"),
|
||||||
// Sync goes on meanwhile, but a reboot ends it until the next login.
|
// 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 *tokens_unsaved => gettext("Sign-in not saved — unlock your keyring"),
|
||||||
|
// An upgrade replaced the files but not the running service, which
|
||||||
|
// keeps the old release's bugs until it restarts.
|
||||||
|
_ if outdated => gettext("Update not finished — restart Proton Drive"),
|
||||||
_ if paused => gettext("Sync paused"),
|
_ if paused => gettext("Sync paused"),
|
||||||
_ if failing_ops > 0 => ngettext_f(
|
_ if failing_ops > 0 => ngettext_f(
|
||||||
"{n} change needs attention",
|
"{n} change needs attention",
|
||||||
@@ -302,6 +314,7 @@ fn status_state(
|
|||||||
mountpoint: PathBuf::from(mountpoint),
|
mountpoint: PathBuf::from(mountpoint),
|
||||||
sync: sync_line(items, jobs),
|
sync: sync_line(items, jobs),
|
||||||
failing: failing_ops,
|
failing: failing_ops,
|
||||||
|
outdated,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,6 +458,13 @@ impl Tray for DriveTray {
|
|||||||
_ => items.push(item(&gettext("Open Proton Drive"), |_| open_manager(&[]))),
|
_ => items.push(item(&gettext("Open Proton Drive"), |_| open_manager(&[]))),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.state.outdated {
|
||||||
|
items.push(item(&gettext("Restart Proton Drive"), |this| {
|
||||||
|
service::restart();
|
||||||
|
this.state.line = gettext("Connecting…");
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
if self.state.mounted {
|
if self.state.mounted {
|
||||||
items.push(
|
items.push(
|
||||||
StandardItem {
|
StandardItem {
|
||||||
@@ -626,9 +646,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn status(extra: &str) -> Response {
|
fn status(extra: &str) -> Response {
|
||||||
|
status_from(env!("CARGO_PKG_VERSION"), extra)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_from(version: &str, extra: &str) -> Response {
|
||||||
serde_json::from_str(&format!(
|
serde_json::from_str(&format!(
|
||||||
r#"{{"Status":{{"username":"u","mountpoint":"/m","pinned":0,"used":0,"budget":0,
|
r#"{{"Status":{{"username":"u","mountpoint":"/m","pinned":0,"used":0,"budget":0,
|
||||||
"pins":[],"online":false,"pending_uploads":3,"failing_ops":2,"paused":true{extra}}}}}"#
|
"pins":[],"online":false,"pending_uploads":3,"failing_ops":2,"paused":true,
|
||||||
|
"version":"{version}"{extra}}}}}"#
|
||||||
))
|
))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
@@ -660,6 +685,16 @@ mod tests {
|
|||||||
assert_eq!(state.line, "Sign-in not saved — unlock your keyring");
|
assert_eq!(state.line, "Sign-in not saved — unlock your keyring");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #33: a daemon an upgrade left running on the old release is named,
|
||||||
|
/// above a pause, and the menu offers the restart that finishes it.
|
||||||
|
#[test]
|
||||||
|
fn a_daemon_older_than_the_tray_needs_a_restart() {
|
||||||
|
let state = status_state(&status_from("0.1.0", ""), &[], &[], Path::new("/m"));
|
||||||
|
assert_eq!(state.phase, Phase::Attention);
|
||||||
|
assert_eq!(state.line, "Update not finished — restart Proton Drive");
|
||||||
|
assert!(state.outdated);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_daemon_that_does_not_say_has_a_live_session() {
|
fn a_daemon_that_does_not_say_has_a_live_session() {
|
||||||
let state = status_state(&status(""), &[], &[], Path::new("/m"));
|
let state = status_state(&status(""), &[], &[], Path::new("/m"));
|
||||||
|
|||||||
+179
@@ -100,6 +100,12 @@ a live account since.
|
|||||||
|
|
||||||
| Entry | Title |
|
| Entry | Title |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| [B210](#b210--after-a-password-reset-the-mount-retries-missing-key-forever) | After a password reset, the mount retries "missing key" forever |
|
||||||
|
| [B209](#b209--a-blocked-sign-in-says-the-app-version-is-no-longer-supported) | A blocked sign-in says the app version is no longer supported |
|
||||||
|
| [B208](#b208--a-file-landing-while-its-folder-is-pinned-is-kept-but-not-available-offline) | A file landing while its folder is pinned is kept but not available offline |
|
||||||
|
| [B207](#b207--a-move-between-locations-goes-ahead-under-a-file-opened-just-after-its-create-landed) | A move between locations goes ahead under a file opened just after its create landed |
|
||||||
|
| [B206](#b206--an-upgrade-leaves-the-old-daemon-running-and-nothing-says-so) | An upgrade leaves the old daemon running, and nothing says so |
|
||||||
|
| [B205](#b205--a-photo-upload-the-sdk-refuses-says-youre-offline) | A photo upload the SDK refuses says "You're offline" |
|
||||||
| [B204](#b204--a-write-over-a-partial-edit-whose-base-was-never-read-fails-with-eio) | A write over a partial edit whose base was never read fails with `EIO` |
|
| [B204](#b204--a-write-over-a-partial-edit-whose-base-was-never-read-fails-with-eio) | A write over a partial edit whose base was never read fails with `EIO` |
|
||||||
| [B203](#b203--pdfs-unlock-leaves-a-running-daemon-a-spent-refresh-token) | `pdfs unlock` leaves a running daemon a spent refresh token |
|
| [B203](#b203--pdfs-unlock-leaves-a-running-daemon-a-spent-refresh-token) | `pdfs unlock` leaves a running daemon a spent refresh token |
|
||||||
| [B202](#b202--a-refused-session-shows-as-offline-forever-and-nothing-says-to-sign-in-again) | A refused session shows as offline forever, and nothing says to sign in again |
|
| [B202](#b202--a-refused-session-shows-as-offline-forever-and-nothing-says-to-sign-in-again) | A refused session shows as offline forever, and nothing says to sign in again |
|
||||||
@@ -307,6 +313,179 @@ a live account since.
|
|||||||
|
|
||||||
Newest first.
|
Newest first.
|
||||||
|
|
||||||
|
### B210 — After a password reset, the mount retries "missing key" forever
|
||||||
|
|
||||||
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-10-15, issue #40: on a fresh 3.4.0 install the sign-in worked, but the daemon
|
||||||
|
logged `mount failed: fetch My Files root: cryptography error: failed to decrypt: missing key`
|
||||||
|
every 5 seconds. The app stayed on "Connecting…" because the daemon never got far enough to
|
||||||
|
create `control.sock`.
|
||||||
|
**Where:** `ProtonDriveClient::ensure_my_files` in proton-drive-rs; `fetch_or_recall_root` in
|
||||||
|
`crates/pdfs-fuse/src/mount.rs`
|
||||||
|
|
||||||
|
**Repro:** Reset the account password through Proton's recovery flow, which replaces the account
|
||||||
|
keys, then sign in on a client without a cache.
|
||||||
|
|
||||||
|
**Cause.** A password reset gives the account new keys and locks the Drive volume whose share was
|
||||||
|
encrypted to the old ones. `v2/shares/my-files` still returns that share, so none of the unlocked
|
||||||
|
address keys can open its passphrase. The SDK only created a new volume when the share did not
|
||||||
|
exist, so every mount attempt failed the same way. The web client checks for an active regular
|
||||||
|
volume and creates a new one when there is none.
|
||||||
|
|
||||||
|
**Fix.** proton-drive-rs 0.9.1 handles a share key that fails to decrypt the way the web client
|
||||||
|
does. When `GET volumes` shows no active regular volume, it creates a new My Files volume and uses
|
||||||
|
that. The locked volume is left alone and can still be restored from the web with the previous
|
||||||
|
password.
|
||||||
|
|
||||||
|
**Test:** `client::tests::a_locked_main_volume_is_not_an_active_regular_one`,
|
||||||
|
`client::tests::an_active_regular_volume_is_found_with_or_without_a_type` (proton-drive-rs).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B209 — A blocked sign-in says the app version is no longer supported
|
||||||
|
|
||||||
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-10-13, issue #39: the first sign-in on a fresh 3.4.0 install showed "This version
|
||||||
|
of the app is no longer supported. Please update to continue using the app." Issue #28 showed the
|
||||||
|
same text on 3.0.0 to 3.1.2, and its debug log gave `path="/auth/v4" http_status=422 code=2028`.
|
||||||
|
Sign-in worked there after a day without attempts, with no change to the client.
|
||||||
|
**Where:** `login_error_message` in `crates/pdfs-gui/src/app/pages/login.rs`; `cmd_login` and
|
||||||
|
`cmd_diagnose` in `crates/pdfs-cli/src/main.rs`
|
||||||
|
|
||||||
|
**Repro:** Needs Proton to block the account or network, which cannot be triggered on purpose.
|
||||||
|
|
||||||
|
**Cause.** Proton answers a sign-in it blocks for abuse or rate limits with code 2028. Its `Error`
|
||||||
|
text claims the version is no longer supported. The SDK had no name for 2028, so it read as
|
||||||
|
`Unknown`, and the app passed Proton's text through. Users were told to update a current release.
|
||||||
|
`pdfs diagnose` also failed the `database` check on a fresh install, before the daemon had ever
|
||||||
|
started, which looked like part of the fault.
|
||||||
|
|
||||||
|
**Fix.** proton-sdk 0.8.3 names 2028 `ResponseCode::Blocked`. `error::sign_in_blocked` detects it,
|
||||||
|
and the app and `pdfs login` say that Proton is refusing sign-in for now and that waiting helps.
|
||||||
|
`pdfs diagnose` warns instead of failing when `cache.db` does not exist yet.
|
||||||
|
|
||||||
|
**Test:** `error::tests::only_code_2028_is_a_blocked_sign_in`,
|
||||||
|
`pages::login::tests::a_blocked_sign_in_does_not_ask_for_an_update`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B208 — A file landing while its folder is pinned is kept but not available offline
|
||||||
|
|
||||||
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-10-11, CI simulation run of `3e4886f`.
|
||||||
|
`sim::daemon::tests::a_pinned_folder_keeps_every_file_under_it_until_it_is_unpinned` waited
|
||||||
|
30 s for `Docs/sub/b.txt` to read as cached and gave up. It failed about once in 25 runs under load
|
||||||
|
and never alone.
|
||||||
|
**Where:** `Core::pin_subtree` in `crates/pdfs-fuse/src/lib.rs`
|
||||||
|
|
||||||
|
**Repro:** Pin a folder while a create in it lands, after the drain has repointed the tree at
|
||||||
|
the real uid and before it has read the node back. The file's cached blob must be gone, as after
|
||||||
|
`pdfs cache clear`, so the pin downloads it.
|
||||||
|
|
||||||
|
**Cause.** The pin walks the folder first and downloads each file under the modification time
|
||||||
|
and size it read then. The landing's read-back then sets the time Drive gave the file, and keeps
|
||||||
|
the uploaded bytes under it. The pin's download ended after that and stored the blob again under
|
||||||
|
the old time. The cache records one time per blob, so the file read as stale: shown as kept
|
||||||
|
offline, but downloaded again on the next read, and unreadable offline.
|
||||||
|
|
||||||
|
**Fix.** After each download the pin compares the file's time and size with the tree's. If they
|
||||||
|
moved, it fetches the file again under the new ones, at most three times.
|
||||||
|
|
||||||
|
**Test:** `sim::daemon::tests::a_folder_pinned_while_a_create_in_it_lands_keeps_that_file` holds
|
||||||
|
the read-back and the pin's download with `FakeClient::hold_next_download`, new for this, and
|
||||||
|
lets them go in that order.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B207 — A move between locations goes ahead under a file opened just after its create landed
|
||||||
|
|
||||||
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-10-11, account acceptance run of 3.3.0, case "move refuses a file still being
|
||||||
|
written" on the on-demand → mirror pairing. `pdfs move` moved the folder while `open.txt` in it
|
||||||
|
was open for writing, and the write landed after the move.
|
||||||
|
**Where:** `Core::has_pending_work_under` in `crates/pdfs-fuse/src/relocate.rs`; `retire_create`
|
||||||
|
in `crates/pdfs-fuse/src/drain.rs`
|
||||||
|
|
||||||
|
**Repro:** Create a file in a mount, and open it for writing right after its create lands, before
|
||||||
|
the drain has repointed the tree at the real uid. Then `pdfs move` its folder to another
|
||||||
|
location. The account run did this by chance: it opened the file about a second after the
|
||||||
|
create landed.
|
||||||
|
|
||||||
|
**Cause.** `retire_create` takes the create out of the queue and re-keys its `nodes` row to the
|
||||||
|
real uid first, then repoints the in-memory tree and the open write handles. A file opened in
|
||||||
|
between gets a write handle that holds the placeholder uid. `has_pending_work_under` placed each
|
||||||
|
open handle by looking its uid up in the `nodes` table, which no longer has the placeholder, so it
|
||||||
|
found nothing open under the folder. The queue was already empty, and the move went ahead.
|
||||||
|
|
||||||
|
**Fix.** Open handles are placed in the in-memory tree by inode, which does not change when a
|
||||||
|
create lands.
|
||||||
|
|
||||||
|
**Test:** `sim::daemon::tests::a_move_out_of_an_on_demand_folder_refuses_a_file_still_open_for_writing`
|
||||||
|
holds the drain at that point with `FakeClient::hold_next_landing_of_any`. The simulation can now
|
||||||
|
sync local folders: the fake Drive lists and registers devices, and the simulated daemons no longer
|
||||||
|
read the developer's own `config.json`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B206 — An upgrade leaves the old daemon running, and nothing says so
|
||||||
|
|
||||||
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-10-11, issue [#33](https://github.com/narrrl/proton-drive-linux/issues/33). On
|
||||||
|
3.2.0 the reporter still saw `Couldn't sync Documents: Read …/snapshots/37: Permission denied`,
|
||||||
|
the B196 failure 3.2.0 fixed. Since 3.2.0 the scan leaves an unreadable subfolder out and
|
||||||
|
`walk_local_dir` can only fail the pass on the sync root, so that message comes from a daemon
|
||||||
|
older than 3.2.0.
|
||||||
|
**Where:** `Response::Status` and `daemon_outdated` in `crates/pdfs-core/src/control.rs`;
|
||||||
|
`status_response` in `crates/pdfs-fuse/src/control.rs`; the tray in `crates/pdfs-gui/src/main.rs`;
|
||||||
|
the sync card in `crates/pdfs-gui/src/app/pages/status.rs`; `pdfs status` and `pdfs diagnose`
|
||||||
|
|
||||||
|
**Repro:** With the service running, install a newer package, then open the app.
|
||||||
|
|
||||||
|
**Cause.** A package upgrade replaces the binaries but does not restart the user service, so the
|
||||||
|
running daemon keeps the old release and its bugs until the next login or a manual restart. The
|
||||||
|
app, the tray and the CLI start from the new binaries and could not tell, so they described the
|
||||||
|
new release while the old one did the work.
|
||||||
|
|
||||||
|
**Fix.** The daemon reports its release in `Status`. A daemon from before the field omits it,
|
||||||
|
which reads as older. When the daemon is older than the front-end, the tray says "Update not
|
||||||
|
finished — restart Proton Drive" with a Restart Proton Drive item, the sync card says "Restart to
|
||||||
|
finish updating" with a Restart button, and `pdfs status` and `pdfs diagnose` print the
|
||||||
|
`systemctl --user restart proton-drive` command. A newer daemon is not flagged: then the
|
||||||
|
front-end is the stale one, and restarting the service would not help.
|
||||||
|
|
||||||
|
**Test:** `control::tests::only_an_older_daemon_is_outdated`;
|
||||||
|
`tests::a_daemon_older_than_the_tray_needs_a_restart` in `pdfs-tray`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B205 — A photo upload the SDK refuses says "You're offline"
|
||||||
|
|
||||||
|
**Status:** Fixed, not verified live
|
||||||
|
**Found:** 2026-10-11, issue #38: uploading an image from the Photos page showed "You're offline"
|
||||||
|
while other uploads went through. Each `upload_photo` failed about 5 µs after it started, with no
|
||||||
|
request sent, and the journal gave no reason.
|
||||||
|
**Where:** `CoreError::from_api` and `From<Error> for CoreError` in `crates/pdfs-core/src/error.rs`;
|
||||||
|
the `UploadPhoto` handler in `crates/pdfs-fuse/src/control.rs`; the Photos page in
|
||||||
|
`crates/pdfs-gui/src/app/pages/photos.rs`
|
||||||
|
|
||||||
|
**Repro:** On an account without a photo library, open Photos and upload an image.
|
||||||
|
|
||||||
|
**Cause.** Every `ProtonError` other than `Api` was classified as `ErrorKind::Offline`, on the
|
||||||
|
reasoning that it never reached Proton. That holds for `Transport` only. `InvalidOperation`,
|
||||||
|
`Crypto`, `KeysLocked` and `Serialization` happen on this machine or after Proton answered. A
|
||||||
|
failure that fast is most likely the SDK's "account has no photos volume", which it returns from
|
||||||
|
its cache without sending a request. The app turns `Offline` into "You're offline", and the
|
||||||
|
daemon only sent the error back, so the journal did not say what it was.
|
||||||
|
|
||||||
|
**Fix.** `proton_kind` classifies by variant: `Transport` is `Offline`, `InvalidOperation` is
|
||||||
|
`Invalid`, `Serialization` is `Remote`, and `Crypto` and `KeysLocked` are `Internal`. The app then
|
||||||
|
shows "Couldn't upload photo" with the daemon's reason. The daemon logs a failed photo upload. The
|
||||||
|
Photos page greys out Upload when the daemon reports no photo library.
|
||||||
|
|
||||||
|
**Test:** `error::tests::a_call_the_sdk_refuses_to_send_is_not_offline`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### B204 — A write over a partial edit whose base was never read fails with `EIO`
|
### B204 — A write over a partial edit whose base was never read fails with `EIO`
|
||||||
|
|
||||||
**Status:** Fixed, not verified live
|
**Status:** Fixed, not verified live
|
||||||
|
|||||||
+89
-2
@@ -11,12 +11,78 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [3.4.1] — 2026-10-16
|
||||||
|
|
||||||
|
Schema: **42**, unchanged.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **After a password reset, the drive never mounted and the app stayed on "Connecting…".** A
|
||||||
|
reset gives the account new keys, and Proton locks the Drive volume that was encrypted to the
|
||||||
|
old ones. The service then failed every start with `failed to decrypt: missing key`. It now
|
||||||
|
starts a new My Files volume, as the web app does. The files in the locked volume can still be
|
||||||
|
restored from [drive.proton.me](https://drive.proton.me) with the previous password
|
||||||
|
(B210, [#40](https://github.com/narrrl/proton-drive-linux/issues/40)).
|
||||||
|
- **A blocked sign-in said the app version was no longer supported.** Proton refuses sign-in for
|
||||||
|
a while after it suspects abuse, and its message then asks you to update. The app and
|
||||||
|
`pdfs login` now say that Proton is refusing sign-in for now and that waiting helps
|
||||||
|
(B209, [#39](https://github.com/narrrl/proton-drive-linux/issues/39)).
|
||||||
|
- **`pdfs diagnose` failed the `database` check on a fresh install.** The daemon creates the
|
||||||
|
database after the first sign-in, so a missing one is now a warning.
|
||||||
|
|
||||||
|
## [3.4.0] — 2026-10-12
|
||||||
|
|
||||||
|
Schema: **42**, unchanged.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **A binary cache for the Nix flake.** Every release and every build of `main` is uploaded to
|
||||||
|
`proton-drive-linux.cachix.org`, so NixOS installs download the client instead of compiling
|
||||||
|
it. See [Install → Binary cache](INSTALL.md#binary-cache).
|
||||||
|
([#37](https://github.com/narrrl/proton-drive-linux/issues/37))
|
||||||
|
- **Folders can be made available offline in the app.** The context menu, the details pane and
|
||||||
|
the selection bar now offer it for folders too. Every file in the folder and its subfolders
|
||||||
|
downloads in the background with its progress in the tray and the app, and pinned folders carry
|
||||||
|
the offline badge. A file kept through its folder shows as available offline but cannot be
|
||||||
|
made online only on its own. `pdfs pin` on a folder now returns at once instead of timing out
|
||||||
|
on a large folder, and pinned files download to disk instead of into memory.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Removing the package left the drive mounted, and deletes there still reached Proton Drive.**
|
||||||
|
The service kept running from the removed program, so deleting `~/ProtonDrive` afterwards moved
|
||||||
|
its files to the Proton Drive trash. The .deb, .rpm and Arch packages now stop the service of
|
||||||
|
every logged-in user before they are removed. Upgrades are not affected.
|
||||||
|
- **`pdfs move` between two locations could move a folder with a file open for writing in it**,
|
||||||
|
when the file had been opened just after it first reached Proton Drive. The write then landed
|
||||||
|
after the move. Such a move is refused now, as it always was meant to be (B207).
|
||||||
|
- **The Fedora package installed KDE packages on GNOME.** Its recommendation of KWallet is now
|
||||||
|
only for Plasma, the GNOME keyring only for desktops other than Plasma, and the AppIndicator
|
||||||
|
extension only for GNOME.
|
||||||
|
|
||||||
|
## [3.3.0] — 2026-10-11
|
||||||
|
|
||||||
|
Schema: **42**, unchanged.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **A Nix flake for NixOS.** `nix build github:narrrl/proton-drive-linux` builds the client, and
|
- **A Nix flake for NixOS.** `nix build github:narrrl/proton-drive-linux` builds the client, and
|
||||||
the flake's NixOS module installs it and runs the mount in every graphical session. See
|
the flake's NixOS module installs it and runs the mount in every graphical session. Pin the
|
||||||
[Install → NixOS](INSTALL.md#nixos-flake).
|
input to a release tag such as `github:narrrl/proton-drive-linux/v3.3.0` to stay on releases.
|
||||||
|
See [Install → NixOS](INSTALL.md#nixos-flake).
|
||||||
([#37](https://github.com/narrrl/proton-drive-linux/issues/37))
|
([#37](https://github.com/narrrl/proton-drive-linux/issues/37))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **A failed photo upload said "You're offline" while everything else worked.** Only a failed
|
||||||
|
connection now reads as offline. Other failures show what went wrong, and Upload is greyed out
|
||||||
|
on an account without a photo library.
|
||||||
|
([#38](https://github.com/narrrl/proton-drive-linux/issues/38))
|
||||||
|
- **After an upgrade, the old version kept running in the background, and nothing said so.**
|
||||||
|
Installing a package does not restart the service, so fixed bugs, such as an unreadable folder
|
||||||
|
stopping a synced folder, came back until the next login. The tray, the app, `pdfs status` and
|
||||||
|
`pdfs diagnose` now say when the service is older than the app, and the tray and the app offer
|
||||||
|
to restart it. ([#33](https://github.com/narrrl/proton-drive-linux/issues/33))
|
||||||
|
|
||||||
|
## [3.2.1] — 2026-10-11
|
||||||
|
|
||||||
|
Schema: **42**, unchanged.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Writing to a file again soon after a partial write could fail with an I/O error.** When the
|
- **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
|
first write changed only part of a file this client had not read, a second write, or saving
|
||||||
@@ -26,6 +92,22 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
the app, as on secureblue, the app now offers it in your browser and continues the sign-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.
|
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))
|
([#29](https://github.com/narrrl/proton-drive-linux/issues/29))
|
||||||
|
- **The daemon kept several CPU cores busy while offline with changes queued.** Its upload
|
||||||
|
workers checked the queue in a tight loop for as long as the outage lasted. They now sleep
|
||||||
|
until the connection comes back. ([#35](https://github.com/narrrl/proton-drive-linux/issues/35))
|
||||||
|
- **An expired sign-in was shown as "offline" for good.** When Proton refuses the session, the
|
||||||
|
tray now says "Session expired — sign in again" with a Sign In Again… item, the app shows the
|
||||||
|
same with a button, and `pdfs status` and `pdfs diagnose` tell you to run `pdfs login`. Cached
|
||||||
|
files stay readable and changes stay queued until you sign in.
|
||||||
|
([#35](https://github.com/narrrl/proton-drive-linux/issues/35))
|
||||||
|
- **The sign-in could be lost after `pdfs login`, `pdfs unlock` or a slow network.** The old
|
||||||
|
daemon wrote its spent tokens over a new sign-in when it stopped, a timeout could drop a token
|
||||||
|
refresh halfway, and a refresh was retried with a token Proton had already used. Each process
|
||||||
|
now writes only into its own session, a refresh always finishes, and it is never repeated.
|
||||||
|
([#35](https://github.com/narrrl/proton-drive-linux/issues/35))
|
||||||
|
- **A sign-in refreshed while the keyring was locked was lost on the next restart.** The client
|
||||||
|
now keeps retrying, holds the new tokens in the kernel keyring meanwhile, and the tray says
|
||||||
|
"Sign-in not saved — unlock your keyring" until it is written.
|
||||||
|
|
||||||
## [3.2.0] — 2026-10-08
|
## [3.2.0] — 2026-10-08
|
||||||
|
|
||||||
@@ -1722,6 +1804,11 @@ 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
|
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
||||||
by a new POSIX compliance suite for the filesystem.
|
by a new POSIX compliance suite for the filesystem.
|
||||||
|
|
||||||
|
[3.4.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.4.1
|
||||||
|
[3.4.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.4.0
|
||||||
|
[3.3.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.3.0
|
||||||
|
[3.2.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.2.1
|
||||||
|
|
||||||
[3.2.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.2.0
|
[3.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.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.2
|
||||||
|
|||||||
+3
-3
@@ -65,9 +65,9 @@ commands are for scripts and for cases where the mount is not available.
|
|||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `pdfs pin PATH` | Download a file completely and keep it on this computer. |
|
| `pdfs pin PATH` | Download a file completely and keep it on this computer. For a folder, its files download in the background (`pdfs transfers` shows the progress). |
|
||||||
| `pdfs unpin PATH` | Release it back to online only. |
|
| `pdfs unpin PATH` | Release it back to online only. A file inside a folder kept offline cannot be released on its own. |
|
||||||
| `pdfs pins` | List files kept available offline. |
|
| `pdfs pins` | List files and folders kept available offline. |
|
||||||
|
|
||||||
## Synced folders
|
## Synced folders
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -153,15 +153,19 @@ Releases are cut from `main` by pushing a `v*` tag. `.github/workflows/release.y
|
|||||||
1. checks that the tag, the workspace version in `Cargo.toml` and `pkgver` in
|
1. checks that the tag, the workspace version in `Cargo.toml` and `pkgver` in
|
||||||
`packaging/PKGBUILD` agree;
|
`packaging/PKGBUILD` agree;
|
||||||
2. runs the quality gates;
|
2. runs the quality gates;
|
||||||
3. builds the release binaries and packages a `.tar.gz`, a `.deb` and a GPG-signed `.rpm`;
|
3. builds the release binaries and packages a `.tar.gz`, a `.deb` and a GPG-signed `.rpm`, and
|
||||||
4. publishes them as a GitHub release;
|
builds the Nix flake at the tag, which is what a flake input pinned to `vX.Y.Z` gets, and
|
||||||
|
pushes it to the `proton-drive-linux` Cachix cache (secret `CACHIX_AUTH_TOKEN`);
|
||||||
|
4. publishes them as a GitHub release, with the version's section of `docs/CHANGELOG.md` as the
|
||||||
|
notes;
|
||||||
5. calls `.github/workflows/aur.yml`, which updates and pushes the AUR recipes and commits them
|
5. calls `.github/workflows/aur.yml`, which updates and pushes the AUR recipes and commits them
|
||||||
back to `main`.
|
back to `main`.
|
||||||
|
|
||||||
To prepare a release:
|
To prepare a release:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Bump the version in Cargo.toml and packaging/PKGBUILD, then refresh the lock file.
|
# 1. Bump the version in Cargo.toml, packaging/PKGBUILD, po/pdfs.pot and the pinned flake
|
||||||
|
# URL in docs/INSTALL.md, then refresh the lock file.
|
||||||
cargo update --workspace
|
cargo update --workspace
|
||||||
# 2. Move the [Unreleased] notes in docs/CHANGELOG.md under the new version.
|
# 2. Move the [Unreleased] notes in docs/CHANGELOG.md under the new version.
|
||||||
# 3. Commit, tag and push.
|
# 3. Commit, tag and push.
|
||||||
|
|||||||
+38
-2
@@ -103,6 +103,35 @@ GNOME Keyring for you:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
That URL follows `main`. To stay on releases, pin the input to a release tag instead, and change
|
||||||
|
the tag to update:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.3.0";
|
||||||
|
```
|
||||||
|
|
||||||
|
Each release tag is built with Nix before the release is published.
|
||||||
|
|
||||||
|
#### Binary cache
|
||||||
|
|
||||||
|
CI uploads every build of `main` and of each release tag to a Cachix binary cache, so Nix can
|
||||||
|
download the client instead of compiling it. A system build does not read a flake input's cache
|
||||||
|
settings, so add the cache to your NixOS configuration once:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
nix.settings = {
|
||||||
|
substituters = [ "https://proton-drive-linux.cachix.org" ];
|
||||||
|
trusted-public-keys = [
|
||||||
|
"proton-drive-linux.cachix.org-1:w1+3pXVXyTlqkFGn3dWpUhlaJtyGhSm3X0mFwfa++AA="
|
||||||
|
];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The cache holds the build against the `nixpkgs` this flake locks. Setting
|
||||||
|
`inputs.proton-drive-linux.inputs.nixpkgs.follows = "nixpkgs"` builds against yours instead, which
|
||||||
|
the cache does not have, so Nix compiles it. `nix run` and `nix shell` ask whether to use the
|
||||||
|
cache the first time; answer yes, or run `cachix use proton-drive-linux`.
|
||||||
|
|
||||||
To try it without changing your system, run the app straight from the flake:
|
To try it without changing your system, run the app straight from the flake:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -273,8 +302,15 @@ pdfs logout # removes the session from the
|
|||||||
```
|
```
|
||||||
|
|
||||||
Then remove the package (`sudo apt remove proton-drive-linux`, `sudo dnf remove
|
Then remove the package (`sudo apt remove proton-drive-linux`, `sudo dnf remove
|
||||||
proton-drive-linux`, or `sudo pacman -R proton-drive-for-linux`), and, if you want no trace left,
|
proton-drive-linux`, or `sudo pacman -R proton-drive-for-linux`). Removing the package also stops
|
||||||
the local data:
|
the service of every logged-in user, in case a step above was skipped.
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> Do not delete `~/ProtonDrive` while it is mounted. It is your Proton Drive, not a copy: every
|
||||||
|
> file deleted there is moved to the Proton Drive trash. Once the service is stopped the folder is
|
||||||
|
> an empty mount point, and removing it is safe.
|
||||||
|
|
||||||
|
If you want no trace left, remove the local data as well:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rm -rf ~/.config/proton-drive-linux ~/.local/state/proton-drive-linux ~/.cache/proton-drive-linux
|
rm -rf ~/.config/proton-drive-linux ~/.local/state/proton-drive-linux ~/.cache/proton-drive-linux
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
| [Configuration](CONFIGURATION.md) | `config.json`, environment variables, file locations, the systemd service |
|
| [Configuration](CONFIGURATION.md) | `config.json`, environment variables, file locations, the systemd service |
|
||||||
| [Troubleshooting](TROUBLESHOOTING.md) | Diagnosing problems, common symptoms, starting over, reporting a bug |
|
| [Troubleshooting](TROUBLESHOOTING.md) | Diagnosing problems, common symptoms, starting over, reporting a bug |
|
||||||
| [Recovering on a new computer](RECOVERY.md) | What survives a lost machine and how to restore your synced folders |
|
| [Recovering on a new computer](RECOVERY.md) | What survives a lost machine and how to restore your synced folders |
|
||||||
|
| [Security model](SECURITY_MODEL.md) | What protects your files, what is kept on this computer, what the client connects to |
|
||||||
| [Changelog](CHANGELOG.md) | What changed in each release |
|
| [Changelog](CHANGELOG.md) | What changed in each release |
|
||||||
|
|
||||||
## For contributors
|
## For contributors
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Security model
|
||||||
|
|
||||||
|
This page explains what protects your files and account when you use this client, what it keeps
|
||||||
|
on your computer, and what it does not protect against. It is a summary for users. The details,
|
||||||
|
with references to the code, are in the
|
||||||
|
[threat model](ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext). To
|
||||||
|
report a vulnerability, see [SECURITY.md](../SECURITY.md).
|
||||||
|
|
||||||
|
This is an unofficial hobby project, not a Proton product. It has had no independent security
|
||||||
|
audit. The source is open under the MIT license, so you can read and build all of it yourself.
|
||||||
|
|
||||||
|
## Encryption
|
||||||
|
|
||||||
|
Proton Drive is end-to-end encrypted, and this client does not change that:
|
||||||
|
|
||||||
|
- **Files are encrypted on this computer before they are uploaded,** and decrypted on this
|
||||||
|
computer after they are downloaded. Proton never receives your keys or the content of your
|
||||||
|
files.
|
||||||
|
- **The cryptography comes from the SDK, not from this client.** The client uses
|
||||||
|
[`proton-sdk` and `proton-drive-rs`](https://github.com/narrrl/proton-sdk-rs), a Rust port of
|
||||||
|
Proton's official C# and TypeScript Drive SDKs. OpenPGP is done by the
|
||||||
|
[rPGP](https://github.com/rpgp/rpgp) library. The client itself does not implement any
|
||||||
|
encryption.
|
||||||
|
- **Your password is not sent to Proton.** Sign-in uses Proton's SRP protocol, which proves that
|
||||||
|
you know the password without sending it.
|
||||||
|
|
||||||
|
## What is kept on this computer
|
||||||
|
|
||||||
|
| What | Where | Protected by |
|
||||||
|
|---|---|---|
|
||||||
|
| Session tokens and the key passphrases | The system keyring (Secret Service: GNOME Keyring, KWallet, KeePassXC) | The keyring's own encryption and your login |
|
||||||
|
| Your password | Nowhere | — |
|
||||||
|
| Cached files, thumbnails and changes waiting to upload | `~/.cache/proton-drive-linux/content/` | File modes only: **not encrypted** |
|
||||||
|
| File and folder names, the folder tree, the activity log | `~/.local/state/proton-drive-linux/cache.db` | File modes only: **not encrypted** |
|
||||||
|
| The SDK's metadata cache | `~/.local/state/proton-drive-linux/sdk_cache.db` | Encrypted with a key derived from your mailbox password |
|
||||||
|
| Settings | `~/.config/proton-drive-linux/config.json` | No secrets are stored here |
|
||||||
|
|
||||||
|
The cache holds decrypted content so that files open fast and work offline. **The client assumes
|
||||||
|
that your disk is encrypted** (LUKS, or an encrypted home directory). On an unencrypted disk,
|
||||||
|
someone who has the powered-off computer can read the cached files and the names in your Drive.
|
||||||
|
|
||||||
|
Accounts with a separate mailbox password (two-password mode) are locked by default. Their key
|
||||||
|
passphrases are kept in the kernel keyring for your login session only, and you unlock again
|
||||||
|
after each login. Storing them in the system keyring is an opt-in.
|
||||||
|
|
||||||
|
## Who can use the client
|
||||||
|
|
||||||
|
- The state, cache and config directories must belong to you and have mode `0700`. The daemon
|
||||||
|
checks this at every start and refuses to run otherwise.
|
||||||
|
- The control socket that the app, tray and `pdfs` use has mode `0600`. Only your user account
|
||||||
|
can connect to it.
|
||||||
|
- Any program that runs as your user can read, change and delete files in `~/ProtonDrive`, the same
|
||||||
|
as in any other folder you own. It is your Proton Drive, not a copy: a deleted file is moved to
|
||||||
|
the Proton Drive trash, where you can restore it.
|
||||||
|
|
||||||
|
## Network connections
|
||||||
|
|
||||||
|
The client connects only to:
|
||||||
|
|
||||||
|
- **Proton**, for the Drive and account API, and for the CAPTCHA page when Proton asks for one.
|
||||||
|
- **OpenFreeMap**, for the street map of Places in Photos, only if you turn it on in Preferences. It is off by default, because the tile server learns which parts of the
|
||||||
|
world you look at. The default map is drawn from data shipped with the app.
|
||||||
|
|
||||||
|
There is no telemetry, no analytics and no update check. The SDK can report timings of its own
|
||||||
|
operations, and the client only writes them to its local log.
|
||||||
|
|
||||||
|
## What this client does not protect against
|
||||||
|
|
||||||
|
- **Malware running as your user.** It can read the cache, connect to the control socket, and use
|
||||||
|
your Drive through the mount, as it can with any data you have access to.
|
||||||
|
- **Swap.** Keys and decrypted data are in ordinary memory and can be written to swap. Use
|
||||||
|
encrypted swap.
|
||||||
|
- **The kernel page cache.** File content read through the mount is cached by the kernel like any
|
||||||
|
other file.
|
||||||
|
|
||||||
|
## Packages
|
||||||
|
|
||||||
|
- The `.rpm` is signed with GPG.
|
||||||
|
- The `.deb` and the `.tar.gz` are built by GitHub Actions from the tagged source, and are not
|
||||||
|
signed.
|
||||||
|
- The AUR packages build from the tagged source or from `main`, or install the release binaries
|
||||||
|
checked against their SHA-256 sums.
|
||||||
|
- The Nix flake builds from source. The binary cache on Cachix holds builds made by CI.
|
||||||
@@ -88,6 +88,13 @@ Quit the app first, so this starts a new instance. The page then runs without We
|
|||||||
so use this only for the sign-in. The stored session keeps working without it. Signing in from
|
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.
|
your usual network, without a VPN, often avoids the CAPTCHA in the first place.
|
||||||
|
|
||||||
|
**"This version of the app is no longer supported"** or **"Proton is temporarily refusing
|
||||||
|
sign-in from this device"** — Proton blocks sign-in for a while after too many attempts or from a
|
||||||
|
network it distrusts, and sends the first message even when the release is current. Do not retry
|
||||||
|
for several hours, up to a day, then sign in once. If it still fails, run
|
||||||
|
`RUST_LOG=debug pdfs login` and look for `code=` in the `proton api request failed` line: 2028 is
|
||||||
|
this block, 5003 is a release Proton no longer accepts.
|
||||||
|
|
||||||
**Sign-in works but is forgotten after a restart** — no Secret Service provider is running, or its
|
**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
|
keyring is locked. Install and unlock GNOME Keyring, KWallet or KeePassXC (with Secret Service
|
||||||
enabled). `pdfs diagnose` shows `keyring session` as failing in that case.
|
enabled). `pdfs diagnose` shows `keyring session` as failing in that case.
|
||||||
@@ -106,6 +113,12 @@ fusermount3 -uz ~/ProtonDrive
|
|||||||
systemctl --user restart proton-drive.service
|
systemctl --user restart proton-drive.service
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**`fetch My Files root: … failed to decrypt: missing key`** in the service log — the account
|
||||||
|
keys cannot open your Drive volume, which happens after a password reset. Proton locks the volume
|
||||||
|
that was encrypted to the old keys. From 3.4.1 the client starts a new, empty My Files volume
|
||||||
|
in that case. Your old files stay in the locked volume: open [drive.proton.me](https://drive.proton.me)
|
||||||
|
and restore them there with your previous password.
|
||||||
|
|
||||||
**The folder is empty or missing** — check that the service runs and is signed in
|
**The folder is empty or missing** — check that the service runs and is signed in
|
||||||
(`pdfs status`). The service waits for a stored session before it mounts.
|
(`pdfs status`). The service waits for a stored session before it mounts.
|
||||||
|
|
||||||
|
|||||||
+8
-5
@@ -44,7 +44,7 @@ The app uses these words consistently, and so does this guide:
|
|||||||
| **My files** | The root of your Drive |
|
| **My files** | The root of your Drive |
|
||||||
| **Proton Drive folder** | Where My files appears on disk, `~/ProtonDrive` by default |
|
| **Proton Drive folder** | Where My files appears on disk, `~/ProtonDrive` by default |
|
||||||
| **Online only** | A file or folder whose content downloads when opened |
|
| **Online only** | A file or folder whose content downloads when opened |
|
||||||
| **Available offline** | A single file kept on this computer |
|
| **Available offline** | A file, or a folder with everything in it, kept on this computer |
|
||||||
| **Synced folder** | A local folder with a full copy on this computer, backed up to Proton Drive |
|
| **Synced folder** | A local folder with a full copy on this computer, backed up to Proton Drive |
|
||||||
| **Proton Drive service** | The background service (`proton-drive.service`) |
|
| **Proton Drive service** | The background service (`proton-drive.service`) |
|
||||||
|
|
||||||
@@ -75,11 +75,14 @@ try to create them get a clean error, not a hang or `EIO`.
|
|||||||
|
|
||||||
## Keeping files on this computer
|
## Keeping files on this computer
|
||||||
|
|
||||||
Turn on **Make available offline** for a file in My files, or run `pdfs pin <path>`. The file is
|
Turn on **Available offline** for a file or folder in My files, or run `pdfs pin <path>`. A file
|
||||||
downloaded completely and never evicted from the cache. `pdfs pins` lists these files, and
|
is downloaded completely and never evicted from the cache. For a folder, every file in it and in
|
||||||
**Preferences → Storage → Available offline** shows them in the app.
|
its subfolders downloads in the background, and the tray and the app show the progress.
|
||||||
|
`pdfs pins` lists what is kept, and **Preferences → Storage → Available offline** shows it in the
|
||||||
|
app.
|
||||||
|
|
||||||
Turning it off (`pdfs unpin <path>`) frees the space. The file stays on Proton Drive.
|
Turning it off (`pdfs unpin <path>`) frees the space. The files stay on Proton Drive. A file inside
|
||||||
|
a folder kept offline stays with its folder: turn the folder off instead.
|
||||||
|
|
||||||
## Syncing local folders
|
## Syncing local folders
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
{
|
{
|
||||||
description = "Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app and tray";
|
description = "Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app and tray";
|
||||||
|
|
||||||
|
# CI pushes every build of `main` and of each release tag here, so installing
|
||||||
|
# downloads the client instead of compiling it. Nix asks before it uses a
|
||||||
|
# flake's caches unless the user trusts them (docs/INSTALL.md).
|
||||||
|
nixConfig = {
|
||||||
|
extra-substituters = [ "https://proton-drive-linux.cachix.org" ];
|
||||||
|
extra-trusted-public-keys = [
|
||||||
|
"proton-drive-linux.cachix.org-1:w1+3pXVXyTlqkFGn3dWpUhlaJtyGhSm3X0mFwfa++AA="
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
|
|||||||
+3
-1
@@ -9,7 +9,7 @@
|
|||||||
# Build + install: cd packaging && makepkg -fi
|
# Build + install: cd packaging && makepkg -fi
|
||||||
|
|
||||||
pkgname=proton-drive-linux
|
pkgname=proton-drive-linux
|
||||||
pkgver=3.2.0
|
pkgver=3.4.1
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -26,6 +26,8 @@ optdepends=('gnome-keyring: credential storage via Secret Service'
|
|||||||
'fzf: live search-as-you-type launcher (pdfs-prompt --fzf)'
|
'fzf: live search-as-you-type launcher (pdfs-prompt --fzf)'
|
||||||
'foot: terminal for pdfs-prompt --fzf started from a keybinding')
|
'foot: terminal for pdfs-prompt --fzf started from a keybinding')
|
||||||
options=('!lto' '!debug')
|
options=('!lto' '!debug')
|
||||||
|
# Stops the running daemon before removal, so the mount stops mirroring deletes.
|
||||||
|
install=proton-drive.install
|
||||||
|
|
||||||
# Built in place from the checkout; nothing to fetch or checksum.
|
# Built in place from the checkout; nothing to fetch or checksum.
|
||||||
source=()
|
source=()
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ The three packages conflict with each other and with the in-tree
|
|||||||
`packaging/PKGBUILD` package (`proton-drive-linux`), and every one of them
|
`packaging/PKGBUILD` package (`proton-drive-linux`), and every one of them
|
||||||
`provides` `proton-drive-for-linux`, `proton-drive-linux`, and `pdfs`.
|
`provides` `proton-drive-for-linux`, `proton-drive-linux`, and `pdfs`.
|
||||||
|
|
||||||
Each directory tracks only `PKGBUILD`, `.SRCINFO`, `.gitignore`, and `LICENSE`
|
Each directory tracks only `PKGBUILD`, `.SRCINFO`, `.gitignore`, `LICENSE` and
|
||||||
|
`proton-drive.install` (a link to `packaging/proton-drive.install`, copied as a file on push)
|
||||||
(the 0BSD license of the packaging sources, per
|
(the 0BSD license of the packaging sources, per
|
||||||
[RFC40](https://rfc.archlinux.page/0040-license-package-sources/)); the
|
[RFC40](https://rfc.archlinux.page/0040-license-package-sources/)); the
|
||||||
`.gitignore` ignores everything else so downloaded sources, `src/`, `pkg/`, and
|
`.gitignore` ignores everything else so downloaded sources, `src/`, `pkg/`, and
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
pkgbase = proton-drive-for-linux-bin
|
pkgbase = proton-drive-for-linux-bin
|
||||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
||||||
pkgver = 3.2.0
|
pkgver = 3.4.0
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://proton-drive.narl.io
|
url = https://proton-drive.narl.io
|
||||||
|
install = proton-drive.install
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
license = MIT
|
license = MIT
|
||||||
depends = fuse3
|
depends = fuse3
|
||||||
@@ -21,21 +22,21 @@ pkgbase = proton-drive-for-linux-bin
|
|||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-for-linux=3.2.0
|
provides = proton-drive-for-linux=3.4.0
|
||||||
provides = proton-drive-linux=3.2.0
|
provides = proton-drive-linux=3.4.0
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux
|
conflicts = proton-drive-for-linux
|
||||||
conflicts = proton-drive-for-linux-git
|
conflicts = proton-drive-for-linux-git
|
||||||
options = !strip
|
options = !strip
|
||||||
options = !debug
|
options = !debug
|
||||||
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 = proton-drive-linux-3.4.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.4.0/proton-drive-linux-3.4.0-x86_64.tar.gz
|
||||||
source = io.narl.proton-drive-linux-3.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-3.4.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.4.0/packaging/io.narl.proton-drive-linux.desktop
|
||||||
source = io.narl.proton-drive-linux-tray-3.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-tray-3.4.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.4.0/packaging/io.narl.proton-drive-linux-tray.desktop
|
||||||
source = io.narl.proton-drive-linux-3.2.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.2.0/packaging/io.narl.proton-drive-linux.svg
|
source = io.narl.proton-drive-linux-3.4.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.4.0/packaging/io.narl.proton-drive-linux.svg
|
||||||
source = proton-drive-3.2.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.2.0/packaging/proton-drive.service
|
source = proton-drive-3.4.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.4.0/packaging/proton-drive.service
|
||||||
source = LICENSE-3.2.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.2.0/LICENSE
|
source = LICENSE-3.4.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.4.0/LICENSE
|
||||||
sha256sums = 3d498c9ea366c94e77e6f3c81b804b8b3497faecfa74480274d6ec9b5121665b
|
sha256sums = bc154c408d572d45847bb9cfdb0e00b94f6d3da6fa484832d8192512de1e2f12
|
||||||
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
||||||
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
||||||
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
||||||
|
|||||||
@@ -5,3 +5,4 @@
|
|||||||
!.SRCINFO
|
!.SRCINFO
|
||||||
!LICENSE
|
!LICENSE
|
||||||
!PKGBUILD
|
!PKGBUILD
|
||||||
|
!proton-drive.install
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux-bin
|
pkgname=proton-drive-for-linux-bin
|
||||||
pkgver=3.2.0
|
pkgver=3.4.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -22,6 +22,8 @@ provides=("${pkgname%-bin}=$pkgver" "proton-drive-linux=$pkgver" 'pdfs')
|
|||||||
conflicts=('proton-drive-linux' 'proton-drive-for-linux' 'proton-drive-for-linux-git')
|
conflicts=('proton-drive-linux' 'proton-drive-for-linux' 'proton-drive-for-linux-git')
|
||||||
# Upstream ships the binaries already built on Ubuntu 24.04; do not rewrite them.
|
# Upstream ships the binaries already built on Ubuntu 24.04; do not rewrite them.
|
||||||
options=('!strip' '!debug')
|
options=('!strip' '!debug')
|
||||||
|
# Stops the running daemon before removal, so the mount stops mirroring deletes.
|
||||||
|
install=proton-drive.install
|
||||||
_tag="v$pkgver"
|
_tag="v$pkgver"
|
||||||
_raw="https://raw.githubusercontent.com/narrrl/proton-drive-linux/$_tag"
|
_raw="https://raw.githubusercontent.com/narrrl/proton-drive-linux/$_tag"
|
||||||
# The release tarball holds the four binaries only, so the desktop integration
|
# The release tarball holds the four binaries only, so the desktop integration
|
||||||
@@ -34,7 +36,7 @@ source=("proton-drive-linux-$pkgver-x86_64.tar.gz::$_repo/releases/download/$_ta
|
|||||||
"io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
|
"io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
|
||||||
"proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service"
|
"proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service"
|
||||||
"LICENSE-$pkgver::$_raw/LICENSE")
|
"LICENSE-$pkgver::$_raw/LICENSE")
|
||||||
sha256sums=('3d498c9ea366c94e77e6f3c81b804b8b3497faecfa74480274d6ec9b5121665b'
|
sha256sums=('bc154c408d572d45847bb9cfdb0e00b94f6d3da6fa484832d8192512de1e2f12'
|
||||||
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
||||||
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
||||||
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
../../proton-drive.install
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
pkgbase = proton-drive-for-linux-git
|
pkgbase = proton-drive-for-linux-git
|
||||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
|
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
|
||||||
pkgver = 3.2.0.r0.g4c498d5
|
pkgver = 3.4.0.r0.gb46abfc
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://proton-drive.narl.io
|
url = https://proton-drive.narl.io
|
||||||
|
install = proton-drive.install
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
license = MIT
|
license = MIT
|
||||||
makedepends = cargo
|
makedepends = cargo
|
||||||
@@ -24,8 +25,8 @@ pkgbase = proton-drive-for-linux-git
|
|||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-for-linux=3.2.0.r0.g4c498d5
|
provides = proton-drive-for-linux=3.4.0.r0.gb46abfc
|
||||||
provides = proton-drive-linux=3.2.0.r0.g4c498d5
|
provides = proton-drive-linux=3.4.0.r0.gb46abfc
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux
|
conflicts = proton-drive-for-linux
|
||||||
|
|||||||
@@ -5,3 +5,4 @@
|
|||||||
!.SRCINFO
|
!.SRCINFO
|
||||||
!LICENSE
|
!LICENSE
|
||||||
!PKGBUILD
|
!PKGBUILD
|
||||||
|
!proton-drive.install
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux-git
|
pkgname=proton-drive-for-linux-git
|
||||||
pkgver=3.2.0.r0.g4c498d5
|
pkgver=3.4.0.r0.gb46abfc
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -23,6 +23,8 @@ provides=("${pkgname%-git}=$pkgver" "proton-drive-linux=$pkgver" 'pdfs')
|
|||||||
conflicts=('proton-drive-linux' 'proton-drive-for-linux' 'proton-drive-for-linux-bin')
|
conflicts=('proton-drive-linux' 'proton-drive-for-linux' 'proton-drive-for-linux-bin')
|
||||||
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
|
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
|
||||||
options=('!lto')
|
options=('!lto')
|
||||||
|
# Stops the running daemon before removal, so the mount stops mirroring deletes.
|
||||||
|
install=proton-drive.install
|
||||||
source=("$pkgname::git+$_repo.git")
|
source=("$pkgname::git+$_repo.git")
|
||||||
sha256sums=('SKIP')
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
../../proton-drive.install
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
pkgbase = proton-drive-for-linux
|
pkgbase = proton-drive-for-linux
|
||||||
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
||||||
pkgver = 3.2.0
|
pkgver = 3.4.0
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://proton-drive.narl.io
|
url = https://proton-drive.narl.io
|
||||||
|
install = proton-drive.install
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
license = MIT
|
license = MIT
|
||||||
makedepends = cargo
|
makedepends = cargo
|
||||||
@@ -23,13 +24,13 @@ pkgbase = proton-drive-for-linux
|
|||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-linux=3.2.0
|
provides = proton-drive-linux=3.4.0
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux-bin
|
conflicts = proton-drive-for-linux-bin
|
||||||
conflicts = proton-drive-for-linux-git
|
conflicts = proton-drive-for-linux-git
|
||||||
options = !lto
|
options = !lto
|
||||||
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
|
source = proton-drive-for-linux-3.4.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.4.0.tar.gz
|
||||||
sha256sums = a15ddb37808dc01bde3a3d092b03b5047cdecca37efd14d906c9bf353d242bd8
|
sha256sums = 1c507afac332d6e89de0c9fe6f37f3a236c31c9e498e3032604ac963f52c1827
|
||||||
|
|
||||||
pkgname = proton-drive-for-linux
|
pkgname = proton-drive-for-linux
|
||||||
@@ -5,3 +5,4 @@
|
|||||||
!.SRCINFO
|
!.SRCINFO
|
||||||
!LICENSE
|
!LICENSE
|
||||||
!PKGBUILD
|
!PKGBUILD
|
||||||
|
!proton-drive.install
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux
|
pkgname=proton-drive-for-linux
|
||||||
pkgver=3.2.0
|
pkgver=3.4.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -23,8 +23,10 @@ provides=("proton-drive-linux=$pkgver" 'pdfs')
|
|||||||
conflicts=('proton-drive-linux' 'proton-drive-for-linux-bin' 'proton-drive-for-linux-git')
|
conflicts=('proton-drive-linux' 'proton-drive-for-linux-bin' 'proton-drive-for-linux-git')
|
||||||
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
|
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
|
||||||
options=('!lto')
|
options=('!lto')
|
||||||
|
# Stops the running daemon before removal, so the mount stops mirroring deletes.
|
||||||
|
install=proton-drive.install
|
||||||
source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz")
|
source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz")
|
||||||
sha256sums=('a15ddb37808dc01bde3a3d092b03b5047cdecca37efd14d906c9bf353d242bd8')
|
sha256sums=('1c507afac332d6e89de0c9fe6f37f3a236c31c9e498e3032604ac963f52c1827')
|
||||||
|
|
||||||
_srcname="proton-drive-linux-$pkgver"
|
_srcname="proton-drive-linux-$pkgver"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
../../proton-drive.install
|
||||||
@@ -21,9 +21,11 @@ for pkg in "${packages[@]}"; do
|
|||||||
# A fresh clone every time: the AUR repository, not this checkout, is what the
|
# A fresh clone every time: the AUR repository, not this checkout, is what the
|
||||||
# commit history has to build on.
|
# commit history has to build on.
|
||||||
git -c init.defaultBranch=master clone -q "ssh://aur@aur.archlinux.org/$pkg.git" "$workdir/$pkg"
|
git -c init.defaultBranch=master clone -q "ssh://aur@aur.archlinux.org/$pkg.git" "$workdir/$pkg"
|
||||||
install -m644 "$pkg/PKGBUILD" "$pkg/.SRCINFO" "$pkg/.gitignore" "$pkg/LICENSE" "$workdir/$pkg/"
|
# proton-drive.install is a link into packaging/; install copies its content.
|
||||||
|
install -m644 "$pkg/PKGBUILD" "$pkg/.SRCINFO" "$pkg/.gitignore" "$pkg/LICENSE" \
|
||||||
|
"$pkg/proton-drive.install" "$workdir/$pkg/"
|
||||||
|
|
||||||
git -C "$workdir/$pkg" add -f PKGBUILD .SRCINFO .gitignore LICENSE
|
git -C "$workdir/$pkg" add -f PKGBUILD .SRCINFO .gitignore LICENSE proton-drive.install
|
||||||
if git -C "$workdir/$pkg" diff --cached --quiet; then
|
if git -C "$workdir/$pkg" diff --cached --quiet; then
|
||||||
echo "$pkg: no changes"
|
echo "$pkg: no changes"
|
||||||
continue
|
continue
|
||||||
|
|||||||
Executable
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Removing the package does not stop a running daemon: it keeps serving the
|
||||||
|
# mount from the deleted binary, so files deleted under the mount afterwards,
|
||||||
|
# `rm -rf ~/ProtonDrive` included, are still deleted on Proton Drive. Stop every
|
||||||
|
# logged-in user's service first; anything still queued stays on disk for the
|
||||||
|
# next install. Same loop as packaging/proton-drive.install.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
remove) ;;
|
||||||
|
*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
for dir in /run/user/*; do
|
||||||
|
user=$(id -nu "${dir##*/}" 2>/dev/null) || continue
|
||||||
|
if runuser -u "$user" -- env XDG_RUNTIME_DIR="$dir" \
|
||||||
|
systemctl --user --quiet is-active proton-drive.service 2>/dev/null; then
|
||||||
|
echo "Stopping Proton Drive for $user, which unmounts its drive."
|
||||||
|
runuser -u "$user" -- env XDG_RUNTIME_DIR="$dir" \
|
||||||
|
systemctl --user stop proton-drive.service 2>/dev/null || :
|
||||||
|
fi
|
||||||
|
done
|
||||||
@@ -37,11 +37,13 @@ Requires: webkitgtk6.0
|
|||||||
Requires: perl-Image-ExifTool
|
Requires: perl-Image-ExifTool
|
||||||
Requires: xdg-utils
|
Requires: xdg-utils
|
||||||
|
|
||||||
# DE-specific; do not Require a single desktop environment.
|
# DE-specific; do not Require a single desktop environment. dnf installs weak
|
||||||
Recommends: gnome-keyring
|
# dependencies by default, so each is conditional on its own desktop: a plain
|
||||||
|
# `Recommends: kwallet` pulled KDE Frameworks onto GNOME installs.
|
||||||
|
Recommends: (gnome-keyring or plasma-workspace)
|
||||||
Recommends: iso-codes
|
Recommends: iso-codes
|
||||||
Recommends: gnome-shell-extension-appindicator
|
Recommends: (gnome-shell-extension-appindicator if gnome-shell)
|
||||||
Recommends: kwallet
|
Recommends: (kwallet if plasma-workspace)
|
||||||
|
|
||||||
# Only for `pdfs-prompt --fzf`; every other front end works without them.
|
# Only for `pdfs-prompt --fzf`; every other front end works without them.
|
||||||
Suggests: fzf
|
Suggests: fzf
|
||||||
@@ -90,6 +92,25 @@ install -D -m0644 %{git_dir}/packaging/proton-drive.service \
|
|||||||
%{buildroot}/usr/lib/systemd/user/proton-drive.service
|
%{buildroot}/usr/lib/systemd/user/proton-drive.service
|
||||||
%{git_dir}/po/build.sh %{buildroot}%{_datadir}/locale
|
%{git_dir}/po/build.sh %{buildroot}%{_datadir}/locale
|
||||||
|
|
||||||
|
# Removing the package does not stop a running daemon: it keeps serving the
|
||||||
|
# mount from the deleted binary, so files deleted under the mount afterwards,
|
||||||
|
# `rm -rf ~/ProtonDrive` included, are still deleted on Proton Drive. Stop every
|
||||||
|
# logged-in user's service first; anything still queued stays on disk for the
|
||||||
|
# next install. $1 is 0 on an erase and 1 on an upgrade. Same loop as
|
||||||
|
# packaging/proton-drive.install.
|
||||||
|
%preun
|
||||||
|
if [ "$1" -eq 0 ]; then
|
||||||
|
for dir in /run/user/*; do
|
||||||
|
user=$(id -nu "${dir##*/}" 2>/dev/null) || continue
|
||||||
|
if runuser -u "$user" -- env XDG_RUNTIME_DIR="$dir" \
|
||||||
|
systemctl --user --quiet is-active proton-drive.service 2>/dev/null; then
|
||||||
|
echo "Stopping Proton Drive for $user, which unmounts its drive."
|
||||||
|
runuser -u "$user" -- env XDG_RUNTIME_DIR="$dir" \
|
||||||
|
systemctl --user stop proton-drive.service 2>/dev/null || :
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
%files
|
%files
|
||||||
%license LICENSE
|
%license LICENSE
|
||||||
%{_bindir}/pdfs
|
%{_bindir}/pdfs
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# pacman install script, shared by packaging/PKGBUILD and the three AUR packages
|
||||||
|
# (each links it). The same loop is in packaging/deb/prerm and the RPM %preun.
|
||||||
|
|
||||||
|
# Removing the package does not stop a running daemon: it keeps serving the
|
||||||
|
# mount from the deleted binary, so files deleted under the mount afterwards,
|
||||||
|
# `rm -rf ~/ProtonDrive` included, are still deleted on Proton Drive. Stop every
|
||||||
|
# logged-in user's service first; anything still queued stays on disk for the
|
||||||
|
# next install. Upgrades call pre_upgrade, not this.
|
||||||
|
pre_remove() {
|
||||||
|
for dir in /run/user/*; do
|
||||||
|
user=$(id -nu "${dir##*/}" 2>/dev/null) || continue
|
||||||
|
if runuser -u "$user" -- env XDG_RUNTIME_DIR="$dir" \
|
||||||
|
systemctl --user --quiet is-active proton-drive.service 2>/dev/null; then
|
||||||
|
echo "Stopping Proton Drive for $user, which unmounts its drive."
|
||||||
|
runuser -u "$user" -- env XDG_RUNTIME_DIR="$dir" \
|
||||||
|
systemctl --user stop proton-drive.service 2>/dev/null || :
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
+682
-617
File diff suppressed because it is too large.
Load diff
+672
-609
File diff suppressed because it is too large.
Load diff
+661
-593
File diff suppressed because it is too large.
Load diff
@@ -4388,6 +4388,21 @@ def test_move_refuses_a_mirror_copy_drive_lacks(mc: MoveContext) -> None:
|
|||||||
# Sync skips symlinks, so Drive never gets this one: removing the local
|
# Sync skips symlinks, so Drive never gets this one: removing the local
|
||||||
# copy after a move would lose it.
|
# copy after a move would lose it.
|
||||||
os.symlink("data.txt", source.root / name / "link")
|
os.symlink("data.txt", source.root / name / "link")
|
||||||
|
try:
|
||||||
|
_move_keeps_what_drive_lacks(mc, source, dest, name, files)
|
||||||
|
finally:
|
||||||
|
# The link stays local-only wherever it ended up, and a mirror folder
|
||||||
|
# holding one refuses the switch to on-demand that the next mode
|
||||||
|
# pairing makes.
|
||||||
|
for location in (source, dest):
|
||||||
|
link = location.root / name / "link"
|
||||||
|
if os.path.lexists(link):
|
||||||
|
os.unlink(link)
|
||||||
|
|
||||||
|
|
||||||
|
def _move_keeps_what_drive_lacks(
|
||||||
|
mc: MoveContext, source: Location, dest: Location, name: str, files: dict[Path, bytes]
|
||||||
|
) -> None:
|
||||||
source.settle()
|
source.settle()
|
||||||
if dest.mode == "mirror" and os.stat(source.folder).st_dev == os.stat(dest.folder).st_dev:
|
if dest.mode == "mirror" and os.stat(source.folder).st_dev == os.stat(dest.folder).st_dev:
|
||||||
# Between two mirrors on one filesystem the local copy is renamed
|
# Between two mirrors on one filesystem the local copy is renamed
|
||||||
|
|||||||
Reference in new issue
Block a user