mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 08:56:59 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
172ec4b0b7 | ||
|
|
ec4f5ea32f | ||
|
|
81a0ecce54 | ||
|
|
cc2f370670 | ||
|
|
b3e8e8ab96 | ||
|
|
8e8d42ad3b | ||
|
|
99e80a7206 | ||
|
|
ca30dceae7 | ||
|
|
13d45ce6f5 | ||
|
|
f31633cb3a | ||
|
|
86fa8aef03 | ||
|
|
aa70077ad0 | ||
|
|
11af393633 |
No files matched your search
@@ -107,71 +107,12 @@ jobs:
|
|||||||
# Build Debian package
|
# Build Debian package
|
||||||
dpkg-deb --build deb proton-drive-linux_${{ env.VERSION }}_amd64.deb
|
dpkg-deb --build deb proton-drive-linux_${{ env.VERSION }}_amd64.deb
|
||||||
|
|
||||||
- name: Package AppImage (.AppImage)
|
|
||||||
run: |
|
|
||||||
# 1. Set up AppDir structure
|
|
||||||
mkdir -p AppDir/usr/bin
|
|
||||||
mkdir -p AppDir/usr/share/applications
|
|
||||||
mkdir -p AppDir/usr/share/icons/hicolor/scalable/apps
|
|
||||||
|
|
||||||
# Copy binaries
|
|
||||||
cp target/release/pdfs AppDir/usr/bin/
|
|
||||||
cp target/release/pdfs-tray AppDir/usr/bin/
|
|
||||||
cp target/release/pdfs-app AppDir/usr/bin/
|
|
||||||
|
|
||||||
# Copy desktop integration files
|
|
||||||
cp packaging/io.narl.proton-drive-linux.desktop AppDir/usr/share/applications/
|
|
||||||
cp packaging/io.narl.proton-drive-linux.svg AppDir/usr/share/icons/hicolor/scalable/apps/
|
|
||||||
|
|
||||||
# Create required AppImage root symlinks
|
|
||||||
ln -s usr/share/applications/io.narl.proton-drive-linux.desktop AppDir/
|
|
||||||
ln -s usr/share/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg AppDir/
|
|
||||||
|
|
||||||
# 2. Write custom AppRun to support multi-call execution
|
|
||||||
cat <<'EOF' > AppDir/AppRun
|
|
||||||
#!/bin/sh
|
|
||||||
HERE="$(dirname "$(readlink -f "${0}")")"
|
|
||||||
ARG0="$(basename "${0}")"
|
|
||||||
case "${ARG0}" in
|
|
||||||
pdfs)
|
|
||||||
exec "${HERE}/usr/bin/pdfs" "$@"
|
|
||||||
;;
|
|
||||||
pdfs-tray)
|
|
||||||
exec "${HERE}/usr/bin/pdfs-tray" "$@"
|
|
||||||
;;
|
|
||||||
pdfs-app)
|
|
||||||
exec "${HERE}/usr/bin/pdfs-app" "$@"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
# Support running a specific binary by passing its name as first arg
|
|
||||||
if [ "$1" = "pdfs" ] || [ "$1" = "pdfs-tray" ] || [ "$1" = "pdfs-app" ]; then
|
|
||||||
CMD="$1"
|
|
||||||
shift
|
|
||||||
exec "${HERE}/usr/bin/${CMD}" "$@"
|
|
||||||
else
|
|
||||||
# Default behavior: run GUI app
|
|
||||||
exec "${HERE}/usr/bin/pdfs-app" "$@"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
EOF
|
|
||||||
chmod +x AppDir/AppRun
|
|
||||||
|
|
||||||
# 3. Download and extract appimagetool (avoids FUSE mount failures in CI)
|
|
||||||
curl -Lo appimagetool https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
|
|
||||||
chmod +x appimagetool
|
|
||||||
./appimagetool --appimage-extract
|
|
||||||
|
|
||||||
# 4. Generate AppImage
|
|
||||||
ARCH=x86_64 ./squashfs-root/AppRun AppDir proton-drive-linux-${{ env.VERSION }}-x86_64.AppImage
|
|
||||||
|
|
||||||
- name: Upload Workflow Artifacts
|
- name: Upload Workflow Artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: proton-drive-linux-builds
|
name: proton-drive-linux-builds
|
||||||
path: |
|
path: |
|
||||||
*.deb
|
*.deb
|
||||||
*.AppImage
|
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
@@ -181,7 +122,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
*.deb
|
*.deb
|
||||||
*.AppImage
|
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
|||||||
Generated
+4
-4
@@ -2772,7 +2772,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap 4.6.1",
|
"clap 4.6.1",
|
||||||
@@ -2787,7 +2787,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"directories",
|
"directories",
|
||||||
"keyring",
|
"keyring",
|
||||||
@@ -2803,7 +2803,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fuser",
|
"fuser",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2816,7 +2816,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
|
|||||||
@@ -9,6 +9,19 @@ A fast, unofficial Proton Drive client for Linux. This client provides a FUSE fi
|
|||||||
- **GTK4 Desktop App**: Log in, browse files, and configure sync options through a modern, native Linux GUI.
|
- **GTK4 Desktop App**: Log in, browse files, and configure sync options through a modern, native Linux GUI.
|
||||||
- **System Tray Integration**: Background indicator for status monitoring, quick actions, and fast sync controls.
|
- **System Tray Integration**: Background indicator for status monitoring, quick actions, and fast sync controls.
|
||||||
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) for safe token storage.
|
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) for safe token storage.
|
||||||
|
- **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI).
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
### GUI Application
|
||||||
|
|
||||||
|
| Login Screen | Account Overview |
|
||||||
|
| :---: | :---: |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
| Files (Grid View) | Files (List View) |
|
||||||
|
| :---: | :---: |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -64,24 +77,7 @@ Install the debian package via `dpkg` or `apt`:
|
|||||||
sudo apt install ./proton-drive-linux_*.deb
|
sudo apt install ./proton-drive-linux_*.deb
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. AppImage (.AppImage)
|
### 2. Arch Linux
|
||||||
The AppImage is a portable build that packages all binaries. Make it executable and run it:
|
|
||||||
```bash
|
|
||||||
chmod +x proton-drive-linux-*.AppImage
|
|
||||||
./proton-drive-linux-*.AppImage
|
|
||||||
```
|
|
||||||
|
|
||||||
> [!TIP]
|
|
||||||
> The AppImage is a multi-call binary. By default, running it launches the GUI. You can run the CLI or the Tray app by passing their name as the first argument, or by renaming/symlinking the AppImage file:
|
|
||||||
> ```bash
|
|
||||||
> # Run CLI
|
|
||||||
> ./proton-drive-linux-*.AppImage pdfs --help
|
|
||||||
>
|
|
||||||
> # Run Tray
|
|
||||||
> ./proton-drive-linux-*.AppImage pdfs-tray
|
|
||||||
> ```
|
|
||||||
|
|
||||||
### 3. Arch Linux
|
|
||||||
A local `PKGBUILD` is available under the `packaging/` directory. You can build and install it using:
|
A local `PKGBUILD` is available under the `packaging/` directory. You can build and install it using:
|
||||||
```bash
|
```bash
|
||||||
cd packaging && makepkg -fi
|
cd packaging && makepkg -fi
|
||||||
@@ -104,9 +100,8 @@ This project has a GitHub Actions CI workflow configured under `.github/workflow
|
|||||||
3. **Artifact Packaging**:
|
3. **Artifact Packaging**:
|
||||||
- Generates a `.tar.gz` containing the raw binaries (`pdfs`, `pdfs-app`, `pdfs-tray`).
|
- Generates a `.tar.gz` containing the raw binaries (`pdfs`, `pdfs-app`, `pdfs-tray`).
|
||||||
- Packs them into a Debian package (`.deb`).
|
- Packs them into a Debian package (`.deb`).
|
||||||
- Builds a portable AppImage using `appimagetool` without requiring FUSE inside CI.
|
|
||||||
4. **Publishing**:
|
4. **Publishing**:
|
||||||
- Creates a GitHub Release matching the pushed tag and uploads the `.deb`, `.AppImage`, and `.tar.gz` packages as release assets.
|
- Creates a GitHub Release matching the pushed tag and uploads the `.deb` and `.tar.gz` packages as release assets.
|
||||||
- For manual runs, compiles and exposes the packages as workflow run artifacts for testing.
|
- For manual runs, compiles and exposes the packages as workflow run artifacts for testing.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+81
-13
@@ -11,7 +11,11 @@ use pdfs_core::config::AppDirs;
|
|||||||
use pdfs_core::control::{Request as CtlRequest, Response as CtlResponse};
|
use pdfs_core::control::{Request as CtlRequest, Response as CtlResponse};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "pdfs", version, about = "Proton Drive for Linux (Files On-Demand)")]
|
#[command(
|
||||||
|
name = "pdfs",
|
||||||
|
version,
|
||||||
|
about = "Proton Drive for Linux (Files On-Demand)"
|
||||||
|
)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Command,
|
command: Command,
|
||||||
@@ -34,6 +38,13 @@ enum Command {
|
|||||||
/// Mountpoint; defaults to ~/ProtonDrive.
|
/// Mountpoint; defaults to ~/ProtonDrive.
|
||||||
mountpoint: Option<PathBuf>,
|
mountpoint: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
|
/// Run the auto-mount daemon: wait for login, mount, and keep it mounted
|
||||||
|
/// until stopped. This is what the systemd user service runs; you normally
|
||||||
|
/// don't invoke it by hand.
|
||||||
|
Daemon {
|
||||||
|
/// Mountpoint; defaults to ~/ProtonDrive.
|
||||||
|
mountpoint: Option<PathBuf>,
|
||||||
|
},
|
||||||
/// Keep a file on this device (download + cache it). Needs a running mount.
|
/// Keep a file on this device (download + cache it). Needs a running mount.
|
||||||
Pin {
|
Pin {
|
||||||
/// File path, inside the mountpoint or relative to it.
|
/// File path, inside the mountpoint or relative to it.
|
||||||
@@ -84,6 +95,7 @@ fn main() -> Result<()> {
|
|||||||
Command::Logout => cmd_logout(),
|
Command::Logout => cmd_logout(),
|
||||||
Command::Status => cmd_status(),
|
Command::Status => cmd_status(),
|
||||||
Command::Mount { mountpoint } => cmd_mount(mountpoint),
|
Command::Mount { mountpoint } => cmd_mount(mountpoint),
|
||||||
|
Command::Daemon { mountpoint } => cmd_daemon(mountpoint),
|
||||||
Command::Pin { path } => cmd_pin(path),
|
Command::Pin { path } => cmd_pin(path),
|
||||||
Command::Unpin { path } => cmd_unpin(path),
|
Command::Unpin { path } => cmd_unpin(path),
|
||||||
Command::Pins => cmd_pins(),
|
Command::Pins => cmd_pins(),
|
||||||
@@ -111,8 +123,7 @@ fn cmd_login(username: Option<String>) -> Result<()> {
|
|||||||
|
|
||||||
// The 2FA prompt fires lazily — only if the account actually requires it.
|
// The 2FA prompt fires lazily — only if the account actually requires it.
|
||||||
let get_totp = || -> pdfs_core::Result<String> {
|
let get_totp = || -> pdfs_core::Result<String> {
|
||||||
prompt_line("2FA code")
|
prompt_line("2FA code").map_err(|e| pdfs_core::Error::Other(format!("read 2FA code: {e}")))
|
||||||
.map_err(|e| pdfs_core::Error::Other(format!("read 2FA code: {e}")))
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let rt = tokio::runtime::Runtime::new()?;
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
@@ -143,7 +154,9 @@ fn cmd_status() -> Result<()> {
|
|||||||
}
|
}
|
||||||
// If a mount daemon is running, report live status from its control socket.
|
// If a mount daemon is running, report live status from its control socket.
|
||||||
match control_request(CtlRequest::Status) {
|
match control_request(CtlRequest::Status) {
|
||||||
Ok(CtlResponse::Status { mountpoint, pinned, .. }) => {
|
Ok(CtlResponse::Status {
|
||||||
|
mountpoint, pinned, ..
|
||||||
|
}) => {
|
||||||
println!("Mounted at {mountpoint} ({pinned} pinned)");
|
println!("Mounted at {mountpoint} ({pinned} pinned)");
|
||||||
}
|
}
|
||||||
Ok(other) => println!("Mount: unexpected response {other:?}"),
|
Ok(other) => println!("Mount: unexpected response {other:?}"),
|
||||||
@@ -153,6 +166,50 @@ fn cmd_status() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_mount(mountpoint: Option<PathBuf>) -> Result<()> {
|
fn cmd_mount(mountpoint: Option<PathBuf>) -> Result<()> {
|
||||||
|
mount_once(mountpoint)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the auto-mount daemon loop. Waits for a stored login, mounts, and keeps
|
||||||
|
/// the mount alive: a clean stop (SIGTERM via `systemctl --user stop`) exits 0;
|
||||||
|
/// an external unmount triggers a remount; errors back off and retry. This is
|
||||||
|
/// the entry point for the systemd user service.
|
||||||
|
fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
|
||||||
|
loop {
|
||||||
|
// Wait until a session is stored. The GUI enables this service on login,
|
||||||
|
// but the service may also start at boot before the user has logged in.
|
||||||
|
loop {
|
||||||
|
match auth::load() {
|
||||||
|
Ok(_) => break,
|
||||||
|
Err(pdfs_core::Error::NotLoggedIn) => {
|
||||||
|
tracing::info!("not logged in; waiting…");
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match mount_once(mountpoint.clone()) {
|
||||||
|
Ok(pdfs_fuse::MountOutcome::Shutdown) => {
|
||||||
|
tracing::info!("daemon stopping");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Ok(pdfs_fuse::MountOutcome::Unmounted) => {
|
||||||
|
tracing::warn!("mount ended externally; remounting in 2s");
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "mount failed; retrying in 5s");
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One mount run: resume the session, mount, and block until the mount ends
|
||||||
|
/// (clean shutdown or external unmount). Returns the [`MountOutcome`] so the
|
||||||
|
/// daemon loop can decide whether to exit or remount.
|
||||||
|
fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
||||||
let dirs = AppDirs::new()?;
|
let dirs = AppDirs::new()?;
|
||||||
dirs.ensure()?;
|
dirs.ensure()?;
|
||||||
let mountpoint = mountpoint.unwrap_or_else(|| dirs.default_mountpoint());
|
let mountpoint = mountpoint.unwrap_or_else(|| dirs.default_mountpoint());
|
||||||
@@ -209,8 +266,14 @@ fn cmd_mount(mountpoint: Option<PathBuf>) -> Result<()> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let handle = rt.handle().clone();
|
let handle = rt.handle().clone();
|
||||||
let result =
|
let result = pdfs_fuse::mount(
|
||||||
pdfs_fuse::mount(client, handle, &mountpoint, cache, &control_socket, username);
|
client,
|
||||||
|
handle,
|
||||||
|
&mountpoint,
|
||||||
|
cache,
|
||||||
|
&control_socket,
|
||||||
|
username,
|
||||||
|
);
|
||||||
|
|
||||||
// Clean unmount: flush whatever tokens the store holds now so the keyring
|
// Clean unmount: flush whatever tokens the store holds now so the keyring
|
||||||
// always ends a session with the live refresh token, not a stale one. Runs
|
// always ends a session with the live refresh token, not a stale one. Runs
|
||||||
@@ -219,12 +282,14 @@ fn cmd_mount(mountpoint: Option<PathBuf>) -> Result<()> {
|
|||||||
tracing::warn!(error = %e, "failed to persist tokens on shutdown");
|
tracing::warn!(error = %e, "failed to persist tokens on shutdown");
|
||||||
}
|
}
|
||||||
|
|
||||||
result.context("mount failed")?;
|
let outcome = result.context("mount failed")?;
|
||||||
Ok(())
|
Ok(outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_pin(path: PathBuf) -> Result<()> {
|
fn cmd_pin(path: PathBuf) -> Result<()> {
|
||||||
match control_request(CtlRequest::Pin { path: path_arg(&path)? })? {
|
match control_request(CtlRequest::Pin {
|
||||||
|
path: path_arg(&path)?,
|
||||||
|
})? {
|
||||||
CtlResponse::Ok { message } => println!("{message}"),
|
CtlResponse::Ok { message } => println!("{message}"),
|
||||||
CtlResponse::Error { message } => bail!("{message}"),
|
CtlResponse::Error { message } => bail!("{message}"),
|
||||||
other => bail!("unexpected response: {other:?}"),
|
other => bail!("unexpected response: {other:?}"),
|
||||||
@@ -233,7 +298,9 @@ fn cmd_pin(path: PathBuf) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_unpin(path: PathBuf) -> Result<()> {
|
fn cmd_unpin(path: PathBuf) -> Result<()> {
|
||||||
match control_request(CtlRequest::Unpin { path: path_arg(&path)? })? {
|
match control_request(CtlRequest::Unpin {
|
||||||
|
path: path_arg(&path)?,
|
||||||
|
})? {
|
||||||
CtlResponse::Ok { message } => println!("{message}"),
|
CtlResponse::Ok { message } => println!("{message}"),
|
||||||
CtlResponse::Error { message } => bail!("{message}"),
|
CtlResponse::Error { message } => bail!("{message}"),
|
||||||
other => bail!("unexpected response: {other:?}"),
|
other => bail!("unexpected response: {other:?}"),
|
||||||
@@ -277,7 +344,9 @@ fn cmd_ls(path: Option<PathBuf>) -> Result<()> {
|
|||||||
|
|
||||||
fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
|
fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
|
||||||
match control_request(CtlRequest::PhotosTimeline { offset, limit })? {
|
match control_request(CtlRequest::PhotosTimeline { offset, limit })? {
|
||||||
CtlResponse::Photos { available: false, .. } => {
|
CtlResponse::Photos {
|
||||||
|
available: false, ..
|
||||||
|
} => {
|
||||||
println!("This account has no photos volume.")
|
println!("This account has no photos volume.")
|
||||||
}
|
}
|
||||||
CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."),
|
CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."),
|
||||||
@@ -307,8 +376,7 @@ fn cmd_open_photo(uid: String) -> Result<()> {
|
|||||||
/// relative paths are passed through and resolved against the mount root.
|
/// relative paths are passed through and resolved against the mount root.
|
||||||
fn path_arg(path: &Path) -> Result<String> {
|
fn path_arg(path: &Path) -> Result<String> {
|
||||||
let p = if path.is_absolute() {
|
let p = if path.is_absolute() {
|
||||||
std::fs::canonicalize(path)
|
std::fs::canonicalize(path).with_context(|| format!("resolve {}", path.display()))?
|
||||||
.with_context(|| format!("resolve {}", path.display()))?
|
|
||||||
} else {
|
} else {
|
||||||
path.to_path_buf()
|
path.to_path_buf()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use proton_sdk::config::ProtonClientConfiguration;
|
|||||||
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::config::{APP_VERSION, KEYRING_SERVICE, USER_AGENT};
|
use crate::config::{APP_VERSION, AppDirs, KEYRING_SERVICE, USER_AGENT};
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
/// Fixed keyring account name for the single stored session blob.
|
/// Fixed keyring account name for the single stored session blob.
|
||||||
@@ -53,7 +53,14 @@ impl StoredSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn client_config() -> ProtonClientConfiguration {
|
fn client_config() -> ProtonClientConfiguration {
|
||||||
ProtonClientConfiguration::new(APP_VERSION).with_user_agent(USER_AGENT)
|
let (app_version, user_agent) = match AppDirs::new() {
|
||||||
|
Ok(dirs) => {
|
||||||
|
let config = dirs.load_config();
|
||||||
|
(config.app_version, config.user_agent)
|
||||||
|
}
|
||||||
|
Err(_) => (APP_VERSION.to_string(), USER_AGENT.to_string()),
|
||||||
|
};
|
||||||
|
ProtonClientConfiguration::new(app_version).with_user_agent(user_agent)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn keyring_entry() -> Result<Entry> {
|
fn keyring_entry() -> Result<Entry> {
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ use sha2::{Digest, Sha256};
|
|||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
|
||||||
|
/// Block size for the on-demand block cache. Matches the SDK content block size
|
||||||
|
/// (`DEFAULT_BLOCK_SIZE`, 4 MiB) so each cached block maps to exactly one
|
||||||
|
/// `download_range` fetch with no straddling.
|
||||||
|
pub const BLOCK_SIZE: u64 = 1 << 22;
|
||||||
|
|
||||||
/// Validity tag stored alongside a cached blob. A blob is fresh only if both
|
/// Validity tag stored alongside a cached blob. A blob is fresh only if both
|
||||||
/// fields still match the node's current metadata.
|
/// fields still match the node's current metadata.
|
||||||
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
|
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -61,6 +66,13 @@ pub struct ContentCache {
|
|||||||
/// Subdirectory holding cached thumbnails (`<key>.t<n>` + `.meta`). Kept out
|
/// Subdirectory holding cached thumbnails (`<key>.t<n>` + `.meta`). Kept out
|
||||||
/// of `content_dir` so the budget scan never sees thumbnail files.
|
/// of `content_dir` so the budget scan never sees thumbnail files.
|
||||||
thumb_dir: PathBuf,
|
thumb_dir: PathBuf,
|
||||||
|
/// Subdirectory holding on-demand block-cache files (`<key>.b<idx>` +
|
||||||
|
/// `.meta`). Kept out of `content_dir` so the whole-file budget scan never
|
||||||
|
/// sees them; blocks carry their own LRU budget.
|
||||||
|
block_dir: PathBuf,
|
||||||
|
/// Subdirectory for write-handle scratch files (disk-backed write buffers).
|
||||||
|
/// Emptied on open so a crashed run leaves no orphans.
|
||||||
|
scratch_dir: PathBuf,
|
||||||
/// JSON pin registry path.
|
/// JSON pin registry path.
|
||||||
pins_path: PathBuf,
|
pins_path: PathBuf,
|
||||||
/// Soft cap on total blob bytes. Exceeded only transiently: a `store`
|
/// Soft cap on total blob bytes. Exceeded only transiently: a `store`
|
||||||
@@ -78,10 +90,24 @@ impl ContentCache {
|
|||||||
std::fs::create_dir_all(&content_dir)?;
|
std::fs::create_dir_all(&content_dir)?;
|
||||||
let thumb_dir = content_dir.join("thumbs");
|
let thumb_dir = content_dir.join("thumbs");
|
||||||
std::fs::create_dir_all(&thumb_dir)?;
|
std::fs::create_dir_all(&thumb_dir)?;
|
||||||
|
let block_dir = content_dir.join("blocks");
|
||||||
|
std::fs::create_dir_all(&block_dir)?;
|
||||||
|
// Scratch holds disk-backed write buffers; a previous run's leftovers are
|
||||||
|
// worthless, so start clean.
|
||||||
|
let scratch_dir = content_dir.join("scratch");
|
||||||
|
let _ = std::fs::remove_dir_all(&scratch_dir);
|
||||||
|
std::fs::create_dir_all(&scratch_dir)?;
|
||||||
if let Some(parent) = pins_path.parent() {
|
if let Some(parent) = pins_path.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
Ok(Self { content_dir, thumb_dir, pins_path, max_bytes })
|
Ok(Self {
|
||||||
|
content_dir,
|
||||||
|
thumb_dir,
|
||||||
|
block_dir,
|
||||||
|
scratch_dir,
|
||||||
|
pins_path,
|
||||||
|
max_bytes,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Filesystem-safe, fixed-length cache key for a uid display string: the
|
/// Filesystem-safe, fixed-length cache key for a uid display string: the
|
||||||
@@ -175,6 +201,123 @@ impl ContentCache {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn block_blob(&self, uid: &NodeUid, idx: u64) -> PathBuf {
|
||||||
|
self.block_dir.join(format!("{}.b{idx}", Self::key(uid)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block_meta(&self, uid: &NodeUid, idx: u64) -> PathBuf {
|
||||||
|
self.block_dir
|
||||||
|
.join(format!("{}.b{idx}.meta", Self::key(uid)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve cached block `idx` (a [`BLOCK_SIZE`]-aligned chunk) of `uid`, or
|
||||||
|
/// `None` on miss/stale. Validated against `(mtime, size)` like a whole-file
|
||||||
|
/// blob, so a new revision (which bumps the mtime) is detected. Bumps the
|
||||||
|
/// block's mtime for LRU, best effort.
|
||||||
|
pub fn cached_block(&self, uid: &NodeUid, mtime: i64, size: u64, idx: u64) -> Option<Vec<u8>> {
|
||||||
|
let want = Meta { mtime, size };
|
||||||
|
let meta: Meta =
|
||||||
|
serde_json::from_slice(&std::fs::read(self.block_meta(uid, idx)).ok()?).ok()?;
|
||||||
|
if meta != want {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut f = std::fs::File::open(self.block_blob(uid, idx)).ok()?;
|
||||||
|
let _ = f.set_times(std::fs::FileTimes::new().set_modified(SystemTime::now()));
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
f.read_to_end(&mut buf).ok()?;
|
||||||
|
Some(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store `bytes` as cached block `idx` of `uid`, tagged `(mtime, size)`.
|
||||||
|
/// Temp-file-then-rename like [`store`](Self::store); meta written last so a
|
||||||
|
/// crash mid-store fails validation. Enforces the block-cache LRU budget.
|
||||||
|
pub fn store_block(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
mtime: i64,
|
||||||
|
size: u64,
|
||||||
|
idx: u64,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> Result<()> {
|
||||||
|
let blob = self.block_blob(uid, idx);
|
||||||
|
let tmp = self
|
||||||
|
.block_dir
|
||||||
|
.join(format!("{}.b{idx}.tmp", Self::key(uid)));
|
||||||
|
std::fs::write(&tmp, bytes)?;
|
||||||
|
std::fs::rename(&tmp, &blob)?;
|
||||||
|
std::fs::write(
|
||||||
|
self.block_meta(uid, idx),
|
||||||
|
serde_json::to_vec(&Meta { mtime, size })?,
|
||||||
|
)?;
|
||||||
|
self.enforce_block_budget();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evict least-recently-used block-cache files until the block dir fits
|
||||||
|
/// `max_bytes`. No-op when the cap is disabled (`0`). All blocks are
|
||||||
|
/// evictable — pinned files are served from whole-file blobs, never blocks.
|
||||||
|
fn enforce_block_budget(&self) {
|
||||||
|
if self.max_bytes == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut total: u64 = 0;
|
||||||
|
let mut blobs: Vec<(PathBuf, u64, SystemTime)> = Vec::new();
|
||||||
|
let Ok(rd) = std::fs::read_dir(&self.block_dir) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for entry in rd.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if name.ends_with(".meta") || name.ends_with(".tmp") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(meta) = entry.metadata() else { continue };
|
||||||
|
if !meta.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
total += meta.len();
|
||||||
|
let atime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
|
||||||
|
blobs.push((path, meta.len(), atime));
|
||||||
|
}
|
||||||
|
if total <= self.max_bytes {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
blobs.sort_by_key(|(_, _, atime)| *atime);
|
||||||
|
for (path, len, _) in blobs {
|
||||||
|
if total <= self.max_bytes {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let meta = path.with_file_name(format!(
|
||||||
|
"{}.meta",
|
||||||
|
path.file_name().unwrap_or_default().to_string_lossy()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let _ = std::fs::remove_file(meta);
|
||||||
|
total = total.saturating_sub(len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a fresh, empty read-write scratch file for a disk-backed write
|
||||||
|
/// handle. Returns the open file and its path (for cleanup on release).
|
||||||
|
pub fn create_scratch(&self) -> Result<(std::fs::File, PathBuf)> {
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
static N: AtomicU64 = AtomicU64::new(0);
|
||||||
|
let path = self.scratch_dir.join(format!(
|
||||||
|
"w-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
N.fetch_add(1, Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
let file = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&path)?;
|
||||||
|
Ok((file, path))
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove any cached blob + meta for `uid`, including its thumbnails (best
|
/// Remove any cached blob + meta for `uid`, including its thumbnails (best
|
||||||
/// effort; absence is fine). Thumbnails are tied to the revision, so a
|
/// effort; absence is fine). Thumbnails are tied to the revision, so a
|
||||||
/// content eviction must drop them too.
|
/// content eviction must drop them too.
|
||||||
@@ -185,6 +328,19 @@ impl ContentCache {
|
|||||||
let _ = std::fs::remove_file(self.thumb_blob(uid, ttype));
|
let _ = std::fs::remove_file(self.thumb_blob(uid, ttype));
|
||||||
let _ = std::fs::remove_file(self.thumb_meta(uid, ttype));
|
let _ = std::fs::remove_file(self.thumb_meta(uid, ttype));
|
||||||
}
|
}
|
||||||
|
// Drop every cached block (and its meta/tmp) for this uid.
|
||||||
|
let prefix = format!("{}.b", Self::key(uid));
|
||||||
|
if let Ok(rd) = std::fs::read_dir(&self.block_dir) {
|
||||||
|
for entry in rd.flatten() {
|
||||||
|
if entry
|
||||||
|
.file_name()
|
||||||
|
.to_str()
|
||||||
|
.is_some_and(|n| n.starts_with(&prefix))
|
||||||
|
{
|
||||||
|
let _ = std::fs::remove_file(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn thumb_blob(&self, uid: &NodeUid, ttype: i32) -> PathBuf {
|
fn thumb_blob(&self, uid: &NodeUid, ttype: i32) -> PathBuf {
|
||||||
@@ -192,7 +348,8 @@ impl ContentCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn thumb_meta(&self, uid: &NodeUid, ttype: i32) -> PathBuf {
|
fn thumb_meta(&self, uid: &NodeUid, ttype: i32) -> PathBuf {
|
||||||
self.thumb_dir.join(format!("{}.t{ttype}.meta", Self::key(uid)))
|
self.thumb_dir
|
||||||
|
.join(format!("{}.t{ttype}.meta", Self::key(uid)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serve a cached thumbnail of `ttype` for `uid`, or `None` on miss/stale.
|
/// Serve a cached thumbnail of `ttype` for `uid`, or `None` on miss/stale.
|
||||||
@@ -267,8 +424,12 @@ impl ContentCache {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Pinned blobs (by cache key) are exempt from eviction.
|
// Pinned blobs (by cache key) are exempt from eviction.
|
||||||
let pinned: HashSet<String> =
|
let pinned: HashSet<String> = self
|
||||||
self.load_pins().pins.keys().map(|uid| Self::key_str(uid)).collect();
|
.load_pins()
|
||||||
|
.pins
|
||||||
|
.keys()
|
||||||
|
.map(|uid| Self::key_str(uid))
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Scan blobs (the `<key>` files; skip `.meta`/`.tmp` siblings), record
|
// Scan blobs (the `<key>` files; skip `.meta`/`.tmp` siblings), record
|
||||||
// size + last-access (mtime) for each.
|
// size + last-access (mtime) for each.
|
||||||
@@ -336,7 +497,10 @@ impl ContentCache {
|
|||||||
let key = uid.to_string();
|
let key = uid.to_string();
|
||||||
file.pins.insert(
|
file.pins.insert(
|
||||||
key.clone(),
|
key.clone(),
|
||||||
Pin { uid: key, path: path.display().to_string() },
|
Pin {
|
||||||
|
uid: key,
|
||||||
|
path: path.display().to_string(),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
self.save_pins(&file)
|
self.save_pins(&file)
|
||||||
}
|
}
|
||||||
@@ -546,4 +710,63 @@ mod tests {
|
|||||||
c.evict(&u);
|
c.evict(&u);
|
||||||
assert!(!c.is_cached(&u, 1, 3));
|
assert!(!c.is_cached(&u, 1, 3));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn block_store_then_read() {
|
||||||
|
let (c, _d) = cache();
|
||||||
|
let u = uid("a");
|
||||||
|
c.store_block(&u, 100, 4096, 0, b"block-zero").unwrap();
|
||||||
|
assert_eq!(c.cached_block(&u, 100, 4096, 0).unwrap(), b"block-zero");
|
||||||
|
// A different index is a separate cache entry, absent here.
|
||||||
|
assert!(c.cached_block(&u, 100, 4096, 1).is_none());
|
||||||
|
// A new revision (mtime/size bump) invalidates the block.
|
||||||
|
assert!(c.cached_block(&u, 101, 4096, 0).is_none());
|
||||||
|
assert!(c.cached_block(&u, 100, 5000, 0).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn evict_drops_blocks() {
|
||||||
|
let (c, _d) = cache();
|
||||||
|
let u = uid("a");
|
||||||
|
c.store_block(&u, 1, 8, 0, b"aaaa").unwrap();
|
||||||
|
c.store_block(&u, 1, 8, 1, b"bbbb").unwrap();
|
||||||
|
c.evict(&u);
|
||||||
|
assert!(c.cached_block(&u, 1, 8, 0).is_none());
|
||||||
|
assert!(c.cached_block(&u, 1, 8, 1).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn block_budget_evicts_lru() {
|
||||||
|
// Cap fits two 4-byte blocks but not three.
|
||||||
|
let (c, _d) = cache_capped(8);
|
||||||
|
let u = uid("a");
|
||||||
|
c.store_block(&u, 1, 12, 0, b"aaaa").unwrap();
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
c.store_block(&u, 1, 12, 1, b"bbbb").unwrap();
|
||||||
|
// Touch block 0 so block 1 is the least-recently-used.
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
assert!(c.cached_block(&u, 1, 12, 0).is_some());
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
c.store_block(&u, 1, 12, 2, b"cccc").unwrap();
|
||||||
|
assert!(
|
||||||
|
c.cached_block(&u, 1, 12, 0).is_some(),
|
||||||
|
"recently-read survives"
|
||||||
|
);
|
||||||
|
assert!(c.cached_block(&u, 1, 12, 1).is_none(), "LRU block evicted");
|
||||||
|
assert!(c.cached_block(&u, 1, 12, 2).is_some(), "newest survives");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scratch_file_is_writable_and_isolated() {
|
||||||
|
use std::io::{Read as _, Seek as _, SeekFrom, Write as _};
|
||||||
|
let (c, _d) = cache();
|
||||||
|
let (mut f1, p1) = c.create_scratch().unwrap();
|
||||||
|
let (_f2, p2) = c.create_scratch().unwrap();
|
||||||
|
assert_ne!(p1, p2, "each scratch file is unique");
|
||||||
|
f1.write_all(b"scratch").unwrap();
|
||||||
|
f1.seek(SeekFrom::Start(0)).unwrap();
|
||||||
|
let mut s = String::new();
|
||||||
|
f1.read_to_string(&mut s).unwrap();
|
||||||
|
assert_eq!(s, "scratch");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use directories::ProjectDirs;
|
use directories::ProjectDirs;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
@@ -18,6 +19,22 @@ pub const KEYRING_SERVICE: &str = "proton-drive-linux";
|
|||||||
/// blobs back under this; pinned files are exempt. See [`crate::cache`].
|
/// blobs back under this; pinned files are exempt. See [`crate::cache`].
|
||||||
pub const DEFAULT_CACHE_BUDGET_BYTES: u64 = 5 * 1024 * 1024 * 1024;
|
pub const DEFAULT_CACHE_BUDGET_BYTES: u64 = 5 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Configuration structure allowing the user to customize client identification headers.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub app_version: String,
|
||||||
|
pub user_agent: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
app_version: APP_VERSION.to_string(),
|
||||||
|
user_agent: USER_AGENT.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolved XDG locations for state, cache, and the default mountpoint.
|
/// Resolved XDG locations for state, cache, and the default mountpoint.
|
||||||
pub struct AppDirs {
|
pub struct AppDirs {
|
||||||
dirs: ProjectDirs,
|
dirs: ProjectDirs,
|
||||||
@@ -30,6 +47,29 @@ impl AppDirs {
|
|||||||
Ok(Self { dirs })
|
Ok(Self { dirs })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Configuration file path (e.g. ~/.config/proton-drive-linux/config.json).
|
||||||
|
pub fn config_path(&self) -> PathBuf {
|
||||||
|
self.dirs.config_dir().join("config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load config from disk, creating default if missing.
|
||||||
|
pub fn load_config(&self) -> AppConfig {
|
||||||
|
let path = self.config_path();
|
||||||
|
if path.exists()
|
||||||
|
&& let Ok(content) = std::fs::read_to_string(&path)
|
||||||
|
&& let Ok(config) = serde_json::from_str::<AppConfig>(&content)
|
||||||
|
{
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
let default_config = AppConfig::default();
|
||||||
|
if let Ok(content) = serde_json::to_string_pretty(&default_config) {
|
||||||
|
let _ = std::fs::create_dir_all(self.dirs.config_dir());
|
||||||
|
let _ = std::fs::write(&path, content);
|
||||||
|
}
|
||||||
|
default_config
|
||||||
|
}
|
||||||
|
|
||||||
/// Persistent state: inode map / cache index DB lives here.
|
/// Persistent state: inode map / cache index DB lives here.
|
||||||
pub fn state_dir(&self) -> PathBuf {
|
pub fn state_dir(&self) -> PathBuf {
|
||||||
// `state_dir` is Linux-only in `directories`; fall back to data dir.
|
// `state_dir` is Linux-only in `directories`; fall back to data dir.
|
||||||
@@ -59,6 +99,11 @@ impl AppDirs {
|
|||||||
self.state_dir().join("control.sock")
|
self.state_dir().join("control.sock")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unix domain socket the tray uses to ensure single instance.
|
||||||
|
pub fn tray_socket(&self) -> PathBuf {
|
||||||
|
self.state_dir().join("tray.sock")
|
||||||
|
}
|
||||||
|
|
||||||
/// Default mountpoint when the user does not pass one explicitly.
|
/// Default mountpoint when the user does not pass one explicitly.
|
||||||
pub fn default_mountpoint(&self) -> PathBuf {
|
pub fn default_mountpoint(&self) -> PathBuf {
|
||||||
directories::UserDirs::new()
|
directories::UserDirs::new()
|
||||||
@@ -70,6 +115,31 @@ impl AppDirs {
|
|||||||
pub fn ensure(&self) -> Result<()> {
|
pub fn ensure(&self) -> Result<()> {
|
||||||
std::fs::create_dir_all(self.state_dir())?;
|
std::fs::create_dir_all(self.state_dir())?;
|
||||||
std::fs::create_dir_all(self.cache_dir())?;
|
std::fs::create_dir_all(self.cache_dir())?;
|
||||||
|
let _ = std::fs::create_dir_all(self.dirs.config_dir());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_app_config_serialization() {
|
||||||
|
let config = AppConfig {
|
||||||
|
app_version: "external-drive-test-client@1.0.0".to_string(),
|
||||||
|
user_agent: "test-agent/1.0".to_string(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0");
|
||||||
|
assert_eq!(decoded.user_agent, "test-agent/1.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_app_config() {
|
||||||
|
let default_config = AppConfig::default();
|
||||||
|
assert_eq!(default_config.app_version, APP_VERSION);
|
||||||
|
assert_eq!(default_config.user_agent, USER_AGENT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,11 +85,19 @@ pub struct PhotoItem {
|
|||||||
/// The daemon's reply to a [`Request`].
|
/// The daemon's reply to a [`Request`].
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub enum Response {
|
pub enum Response {
|
||||||
/// Current mount status.
|
/// Current mount status. Carries the cache stats the daemon already holds
|
||||||
|
/// (`used`/`budget` bytes and the pin list) so a front-end never has to open
|
||||||
|
/// the on-disk cache itself on its UI thread.
|
||||||
Status {
|
Status {
|
||||||
username: String,
|
username: String,
|
||||||
mountpoint: String,
|
mountpoint: String,
|
||||||
pinned: usize,
|
pinned: usize,
|
||||||
|
/// Bytes of cached content blobs (see [`crate::cache::ContentCache::usage`]).
|
||||||
|
used: u64,
|
||||||
|
/// Configured soft byte cap (`0` = unlimited).
|
||||||
|
budget: u64,
|
||||||
|
/// The pin registry.
|
||||||
|
pins: Vec<Pin>,
|
||||||
},
|
},
|
||||||
/// A human-readable success message.
|
/// A human-readable success message.
|
||||||
Ok { message: String },
|
Ok { message: String },
|
||||||
@@ -99,7 +107,10 @@ pub enum Response {
|
|||||||
Entries { entries: Vec<DirEntry> },
|
Entries { entries: Vec<DirEntry> },
|
||||||
/// A page of the photos timeline. `available` is false when the account
|
/// A page of the photos timeline. `available` is false when the account
|
||||||
/// has no photos volume.
|
/// has no photos volume.
|
||||||
Photos { available: bool, items: Vec<PhotoItem> },
|
Photos {
|
||||||
|
available: bool,
|
||||||
|
items: Vec<PhotoItem>,
|
||||||
|
},
|
||||||
/// An on-disk path the front-end can open (e.g. a downloaded photo).
|
/// An on-disk path the front-end can open (e.g. a downloaded photo).
|
||||||
FilePath { path: String },
|
FilePath { path: String },
|
||||||
/// The request failed.
|
/// The request failed.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub mod cache;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod control;
|
pub mod control;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod service;
|
||||||
|
|
||||||
pub use error::{Error, Result};
|
pub use error::{Error, Result};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
//! Thin wrappers over `systemctl --user` for the auto-mount daemon.
|
||||||
|
//!
|
||||||
|
//! The mount is owned by a systemd *user* service (`proton-drive.service`, see
|
||||||
|
//! `packaging/`). Front-ends never spawn `pdfs mount` directly anymore: logging
|
||||||
|
//! in enables+starts the unit, logging out disables+stops it, and the tray's
|
||||||
|
//! "Disconnect" just stops it. systemd then handles restart-on-failure and a
|
||||||
|
//! clean SIGTERM stop (which the daemon turns into a lazy unmount).
|
||||||
|
//!
|
||||||
|
//! Every call is best-effort: a failure is logged but not fatal, since the user
|
||||||
|
//! can always drive `systemctl --user` by hand.
|
||||||
|
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
/// The systemd user unit that runs `pdfs daemon`.
|
||||||
|
pub const SERVICE: &str = "proton-drive.service";
|
||||||
|
|
||||||
|
/// Run one `systemctl --user <args> proton-drive.service`, returning whether it
|
||||||
|
/// succeeded. Output is suppressed; failures are logged.
|
||||||
|
fn systemctl(args: &[&str]) -> bool {
|
||||||
|
let ok = Command::new("systemctl")
|
||||||
|
.arg("--user")
|
||||||
|
.args(args)
|
||||||
|
.arg(SERVICE)
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !ok {
|
||||||
|
tracing::warn!(args = ?args, "systemctl --user {SERVICE} failed");
|
||||||
|
}
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`systemctl`] but treats a non-zero exit as a plain `false` answer
|
||||||
|
/// rather than a failure to log. For query verbs (`is-active`, `is-failed`)
|
||||||
|
/// where "no" is the normal, expected result.
|
||||||
|
fn systemctl_query(args: &[&str]) -> bool {
|
||||||
|
Command::new("systemctl")
|
||||||
|
.arg("--user")
|
||||||
|
.args(args)
|
||||||
|
.arg(SERVICE)
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable the service so it survives reboots/logins and start it now. Called on
|
||||||
|
/// successful login.
|
||||||
|
pub fn enable_start() -> bool {
|
||||||
|
systemctl(&["enable", "--now"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the running mount without disabling it. Called by the tray's
|
||||||
|
/// "Disconnect": the next login (or reboot) brings it back.
|
||||||
|
pub fn stop() -> bool {
|
||||||
|
systemctl(&["stop"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restart the unit now. Used by a front-end "Retry" action when the mount has
|
||||||
|
/// failed and the user wants to bring it back without re-logging-in.
|
||||||
|
pub fn restart() -> bool {
|
||||||
|
systemctl(&["restart"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the unit currently reports `active` (running, or starting up).
|
||||||
|
/// `systemctl is-active` exits 0 only while the unit is active, so a front-end
|
||||||
|
/// can use this to tell "service still coming up" from "service is down".
|
||||||
|
pub fn is_active() -> bool {
|
||||||
|
systemctl_query(&["is-active"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the unit is in the `failed` state. `systemctl is-failed` exits 0
|
||||||
|
/// only when the unit has failed, letting a front-end show an actionable error
|
||||||
|
/// (with Retry) rather than an indefinite "connecting…" spinner.
|
||||||
|
pub fn is_failed() -> bool {
|
||||||
|
systemctl_query(&["is-failed"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disable the service so it no longer auto-starts, and stop it now. Called on
|
||||||
|
/// logout.
|
||||||
|
pub fn disable_stop() -> bool {
|
||||||
|
systemctl(&["disable", "--now"])
|
||||||
|
}
|
||||||
+728
-171
File diff suppressed because it is too large.
Load diff
+479
-193
File diff suppressed because it is too large.
Load diff
+27
-32
@@ -19,6 +19,7 @@ use ksni::{MenuItem, Tray, TrayService};
|
|||||||
use pdfs_core::auth;
|
use pdfs_core::auth;
|
||||||
use pdfs_core::config::AppDirs;
|
use pdfs_core::config::AppDirs;
|
||||||
use pdfs_core::control::{Request, Response, send};
|
use pdfs_core::control::{Request, Response, send};
|
||||||
|
use pdfs_core::service;
|
||||||
|
|
||||||
/// How often the tray re-polls the daemon to refresh its menu.
|
/// How often the tray re-polls the daemon to refresh its menu.
|
||||||
const POLL_INTERVAL: Duration = Duration::from_secs(3);
|
const POLL_INTERVAL: Duration = Duration::from_secs(3);
|
||||||
@@ -42,7 +43,9 @@ struct DriveTray {
|
|||||||
/// menu can still say "Logged in …" / "Not logged in" when no mount is running.
|
/// menu can still say "Logged in …" / "Not logged in" when no mount is running.
|
||||||
fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||||
match send(socket, &Request::Status) {
|
match send(socket, &Request::Status) {
|
||||||
Ok(Response::Status { mountpoint, pinned, .. }) => DriveState {
|
Ok(Response::Status {
|
||||||
|
mountpoint, pinned, ..
|
||||||
|
}) => DriveState {
|
||||||
line: format!("Mounted at {mountpoint} ({pinned} pinned)"),
|
line: format!("Mounted at {mountpoint} ({pinned} pinned)"),
|
||||||
mounted: true,
|
mounted: true,
|
||||||
mountpoint: PathBuf::from(mountpoint),
|
mountpoint: PathBuf::from(mountpoint),
|
||||||
@@ -69,26 +72,6 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Launch `pdfs mount` detached so the tray keeps running independently.
|
|
||||||
fn spawn_mount() {
|
|
||||||
match Command::new("pdfs").arg("mount").spawn() {
|
|
||||||
Ok(_) => tracing::info!("spawned `pdfs mount`"),
|
|
||||||
Err(e) => tracing::error!("failed to spawn `pdfs mount`: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unmount the FUSE filesystem. `fusermount3 -u` is the FUSE-native teardown;
|
|
||||||
/// fall back to `fusermount` on older systems.
|
|
||||||
fn unmount(mountpoint: &Path) {
|
|
||||||
let try_unmount = |bin: &str| Command::new(bin).arg("-u").arg(mountpoint).status();
|
|
||||||
let result = try_unmount("fusermount3").or_else(|_| try_unmount("fusermount"));
|
|
||||||
match result {
|
|
||||||
Ok(s) if s.success() => tracing::info!("unmounted {}", mountpoint.display()),
|
|
||||||
Ok(s) => tracing::error!("unmount exited with {s}"),
|
|
||||||
Err(e) => tracing::error!("failed to run fusermount: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn open_folder(mountpoint: &Path) {
|
fn open_folder(mountpoint: &Path) {
|
||||||
if let Err(e) = Command::new("xdg-open").arg(mountpoint).spawn() {
|
if let Err(e) = Command::new("xdg-open").arg(mountpoint).spawn() {
|
||||||
tracing::error!("failed to xdg-open {}: {e}", mountpoint.display());
|
tracing::error!("failed to xdg-open {}: {e}", mountpoint.display());
|
||||||
@@ -109,9 +92,8 @@ impl Tray for DriveTray {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn menu(&self) -> Vec<MenuItem<Self>> {
|
fn menu(&self) -> Vec<MenuItem<Self>> {
|
||||||
// Values captured into the action closures (which only get `&mut Self`).
|
// Value captured into the action closure (which only gets `&mut Self`).
|
||||||
let mountpoint = self.state.mountpoint.clone();
|
let open_mp = self.state.mountpoint.clone();
|
||||||
let open_mp = mountpoint.clone();
|
|
||||||
|
|
||||||
let mut items: Vec<MenuItem<Self>> = vec![
|
let mut items: Vec<MenuItem<Self>> = vec![
|
||||||
StandardItem {
|
StandardItem {
|
||||||
@@ -132,25 +114,28 @@ impl Tray for DriveTray {
|
|||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
|
// Stop the systemd service (clean SIGTERM → lazy unmount). The next
|
||||||
|
// login or reboot brings it back; this is a deliberate disconnect.
|
||||||
items.push(
|
items.push(
|
||||||
StandardItem {
|
StandardItem {
|
||||||
label: "Unmount".into(),
|
label: "Disconnect".into(),
|
||||||
activate: Box::new(move |this: &mut Self| {
|
activate: Box::new(move |this: &mut Self| {
|
||||||
unmount(&mountpoint);
|
service::stop();
|
||||||
this.state.mounted = false;
|
this.state.mounted = false;
|
||||||
this.state.line = "Unmounting…".into();
|
this.state.line = "Disconnecting…".into();
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
// Enable+start the service so it mounts now and on future logins.
|
||||||
items.push(
|
items.push(
|
||||||
StandardItem {
|
StandardItem {
|
||||||
label: "Mount".into(),
|
label: "Connect".into(),
|
||||||
activate: Box::new(|this: &mut Self| {
|
activate: Box::new(|this: &mut Self| {
|
||||||
spawn_mount();
|
service::enable_start();
|
||||||
this.state.line = "Mounting…".into();
|
this.state.line = "Connecting…".into();
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
@@ -174,12 +159,22 @@ impl Tray for DriveTray {
|
|||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(
|
.with_env_filter(
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||||
.unwrap_or_else(|_| "info".into()),
|
|
||||||
)
|
)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let dirs = AppDirs::new().context("resolve app dirs")?;
|
let dirs = AppDirs::new().context("resolve app dirs")?;
|
||||||
|
|
||||||
|
// Ensure only one instance of the tray runs.
|
||||||
|
let tray_sock = dirs.tray_socket();
|
||||||
|
if std::os::unix::net::UnixStream::connect(&tray_sock).is_ok() {
|
||||||
|
tracing::info!("Another instance of pdfs-tray is already running; exiting.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_file(&tray_sock);
|
||||||
|
let _lock_socket = std::os::unix::net::UnixListener::bind(&tray_sock)
|
||||||
|
.context("failed to bind tray single-instance socket")?;
|
||||||
|
|
||||||
let socket = dirs.control_socket();
|
let socket = dirs.control_socket();
|
||||||
let default_mountpoint = dirs.default_mountpoint();
|
let default_mountpoint = dirs.default_mountpoint();
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 200 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 211 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 191 KiB |
+9
-2
@@ -8,7 +8,7 @@
|
|||||||
# Build + install: cd packaging && makepkg -fi
|
# Build + install: cd packaging && makepkg -fi
|
||||||
|
|
||||||
pkgname=proton-drive-linux
|
pkgname=proton-drive-linux
|
||||||
pkgver=0.1.0
|
pkgver=0.1.2
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -20,7 +20,7 @@ provides=('pdfs')
|
|||||||
# Files-on-demand mount + keyring need these at runtime.
|
# Files-on-demand mount + keyring need these at runtime.
|
||||||
optdepends=('gnome-keyring: credential storage via Secret Service'
|
optdepends=('gnome-keyring: credential storage via Secret Service'
|
||||||
'xdg-utils: "Open folder" action from the tray')
|
'xdg-utils: "Open folder" action from the tray')
|
||||||
options=('!lto')
|
options=('!lto' '!debug')
|
||||||
|
|
||||||
# Built in place from the checkout; nothing to fetch or checksum.
|
# Built in place from the checkout; nothing to fetch or checksum.
|
||||||
source=()
|
source=()
|
||||||
@@ -56,8 +56,15 @@ package() {
|
|||||||
|
|
||||||
install -Dm644 "packaging/io.narl.proton-drive-linux.desktop" \
|
install -Dm644 "packaging/io.narl.proton-drive-linux.desktop" \
|
||||||
"$pkgdir/usr/share/applications/io.narl.proton-drive-linux.desktop"
|
"$pkgdir/usr/share/applications/io.narl.proton-drive-linux.desktop"
|
||||||
|
install -Dm644 "packaging/io.narl.proton-drive-linux-tray.desktop" \
|
||||||
|
"$pkgdir/etc/xdg/autostart/io.narl.proton-drive-linux-tray.desktop"
|
||||||
install -Dm644 "packaging/io.narl.proton-drive-linux.svg" \
|
install -Dm644 "packaging/io.narl.proton-drive-linux.svg" \
|
||||||
"$pkgdir/usr/share/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg"
|
"$pkgdir/usr/share/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg"
|
||||||
|
|
||||||
|
# systemd user service that runs the auto-mount daemon. Enabled per-user by
|
||||||
|
# the GUI on login (`systemctl --user enable --now proton-drive.service`).
|
||||||
|
install -Dm644 "packaging/proton-drive.service" \
|
||||||
|
"$pkgdir/usr/lib/systemd/user/proton-drive.service"
|
||||||
|
|
||||||
install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Proton Drive (Tray)
|
||||||
|
GenericName=Cloud Drive Tray
|
||||||
|
Comment=Proton Drive system tray icon
|
||||||
|
Exec=pdfs-tray
|
||||||
|
Icon=io.narl.proton-drive-linux
|
||||||
|
Terminal=false
|
||||||
|
Categories=Network;FileTransfer;GTK;
|
||||||
|
Keywords=proton;drive;cloud;sync;storage;
|
||||||
|
NoDisplay=true
|
||||||
@@ -10,5 +10,5 @@
|
|||||||
<!-- cloud -->
|
<!-- cloud -->
|
||||||
<path fill="#ffffff" d="M44 84c-11 0-20-9-20-20 0-10 7-18 17-20 3-11 13-19 25-19 13 0 24 10 26 23 9 1 16 9 16 18 0 10-8 18-18 18z"/>
|
<path fill="#ffffff" d="M44 84c-11 0-20-9-20-20 0-10 7-18 17-20 3-11 13-19 25-19 13 0 24 10 26 23 9 1 16 9 16 18 0 10-8 18-18 18z"/>
|
||||||
<!-- down arrow (on-demand pull) -->
|
<!-- down arrow (on-demand pull) -->
|
||||||
<path fill="#6d4aff" d="M64 50v22m0 0l-9-9m9 9l9-9" stroke="#6d4aff" stroke-width="7" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
<path d="M64 50v22m0 0l-9-9m9 9l9-9" stroke="#6d4aff" stroke-width="7" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 733 B After Width: | Height: | Size: 718 B |
@@ -0,0 +1,19 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Proton Drive Files-On-Demand mount
|
||||||
|
Documentation=https://github.com/narl/proton-drive-linux
|
||||||
|
# Tie the mount to the desktop session: start after it is up and stop with it.
|
||||||
|
After=graphical-session.target
|
||||||
|
PartOf=graphical-session.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# The daemon waits for a stored login, mounts ~/ProtonDrive, and keeps it
|
||||||
|
# mounted. SIGTERM (from `systemctl --user stop`) triggers a clean lazy unmount.
|
||||||
|
ExecStart=/usr/bin/pdfs daemon
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
# Give the lazy unmount time to finish before SIGKILL.
|
||||||
|
TimeoutStopSec=15
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
Reference in new issue
Block a user