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