mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca30dceae7 | ||
|
|
13d45ce6f5 | ||
|
|
f31633cb3a | ||
|
|
86fa8aef03 | ||
|
|
aa70077ad0 |
No files matched your search
Generated
+4
-4
@@ -2772,7 +2772,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
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.1.2"
|
||||||
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.1.2"
|
||||||
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.1.2"
|
||||||
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.1.2"
|
||||||
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) |
|
||||||
|
| :---: | :---: |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+27
-11
@@ -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,
|
||||||
@@ -111,8 +115,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 +146,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:?}"),
|
||||||
@@ -209,8 +214,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
|
||||||
@@ -224,7 +235,9 @@ fn cmd_mount(mountpoint: Option<PathBuf>) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 +246,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 +292,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 +324,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> {
|
||||||
|
|||||||
@@ -81,7 +81,12 @@ impl ContentCache {
|
|||||||
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,
|
||||||
|
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
|
||||||
@@ -192,7 +197,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 +273,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 +346,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,28 @@ 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 +98,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 +114,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,7 +99,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.
|
||||||
|
|||||||
+176
-62
@@ -32,13 +32,13 @@ use std::path::{Component, Path, PathBuf};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use fuser::{
|
|
||||||
BsdFileFlags, Config, Errno, FileAttr, FileHandle, FileType, Filesystem, FopenFlags, Generation,
|
|
||||||
INodeNo, LockOwner, MountOption, Notifier, OpenAccMode, OpenFlags, RenameFlags, ReplyAttr,
|
|
||||||
ReplyCreate, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyOpen, ReplyWrite, Request,
|
|
||||||
Session, TimeOrNow, WriteFlags,
|
|
||||||
};
|
|
||||||
use fuser::ReplyXattr;
|
use fuser::ReplyXattr;
|
||||||
|
use fuser::{
|
||||||
|
BsdFileFlags, Config, Errno, FileAttr, FileHandle, FileType, Filesystem, FopenFlags,
|
||||||
|
Generation, INodeNo, LockOwner, MountOption, Notifier, OpenAccMode, OpenFlags, RenameFlags,
|
||||||
|
ReplyAttr, ReplyCreate, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyOpen,
|
||||||
|
ReplyWrite, Request, Session, TimeOrNow, WriteFlags,
|
||||||
|
};
|
||||||
use pdfs_core::cache::ContentCache;
|
use pdfs_core::cache::ContentCache;
|
||||||
use pdfs_core::control::{DirEntry, PhotoItem, Request as CtlRequest, Response as CtlResponse};
|
use pdfs_core::control::{DirEntry, PhotoItem, Request as CtlRequest, Response as CtlResponse};
|
||||||
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId};
|
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId};
|
||||||
@@ -120,8 +120,14 @@ impl State {
|
|||||||
let ino = self.next_ino;
|
let ino = self.next_ino;
|
||||||
self.next_ino += 1;
|
self.next_ino += 1;
|
||||||
self.by_uid.insert(node.uid.clone(), ino);
|
self.by_uid.insert(node.uid.clone(), ino);
|
||||||
self.entries
|
self.entries.insert(
|
||||||
.insert(ino, Entry { uid: node.uid.clone(), parent, node });
|
ino,
|
||||||
|
Entry {
|
||||||
|
uid: node.uid.clone(),
|
||||||
|
parent,
|
||||||
|
node,
|
||||||
|
},
|
||||||
|
);
|
||||||
ino
|
ino
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,14 +258,16 @@ impl Core {
|
|||||||
let mut ino = ROOT_INO;
|
let mut ino = ROOT_INO;
|
||||||
let mut uid = {
|
let mut uid = {
|
||||||
let st = self.state.lock().unwrap();
|
let st = self.state.lock().unwrap();
|
||||||
st.entries.get(&ROOT_INO).map(|e| e.uid.clone()).ok_or(Errno::ENOENT)?
|
st.entries
|
||||||
|
.get(&ROOT_INO)
|
||||||
|
.map(|e| e.uid.clone())
|
||||||
|
.ok_or(Errno::ENOENT)?
|
||||||
};
|
};
|
||||||
for comp in rel.components() {
|
for comp in rel.components() {
|
||||||
match comp {
|
match comp {
|
||||||
Component::RootDir | Component::CurDir => continue,
|
Component::RootDir | Component::CurDir => continue,
|
||||||
Component::Normal(name) => {
|
Component::Normal(name) => {
|
||||||
let (child_ino, child_uid) =
|
let (child_ino, child_uid) = self.lookup_child(ino, &name.to_string_lossy())?;
|
||||||
self.lookup_child(ino, &name.to_string_lossy())?;
|
|
||||||
ino = child_ino;
|
ino = child_ino;
|
||||||
uid = child_uid;
|
uid = child_uid;
|
||||||
}
|
}
|
||||||
@@ -320,14 +328,20 @@ impl Core {
|
|||||||
/// into the content cache and record it in the pin registry. Returns the
|
/// into the content cache and record it in the pin registry. Returns the
|
||||||
/// resolved node name on success.
|
/// resolved node name on success.
|
||||||
fn pin(&self, rel: &Path) -> Result<String, String> {
|
fn pin(&self, rel: &Path) -> Result<String, String> {
|
||||||
let (ino, uid) = self.resolve_path(rel).map_err(|e| format!("resolve path: {e:?}"))?;
|
let (ino, uid) = self
|
||||||
|
.resolve_path(rel)
|
||||||
|
.map_err(|e| format!("resolve path: {e:?}"))?;
|
||||||
let (name, mtime, size) = {
|
let (name, mtime, size) = {
|
||||||
let st = self.state.lock().unwrap();
|
let st = self.state.lock().unwrap();
|
||||||
let e = st.entries.get(&ino).ok_or("node vanished")?;
|
let e = st.entries.get(&ino).ok_or("node vanished")?;
|
||||||
if !e.node.is_file() {
|
if !e.node.is_file() {
|
||||||
return Err("not a regular file".into());
|
return Err("not a regular file".into());
|
||||||
}
|
}
|
||||||
(e.node.name.clone(), e.node.modification_time, node_size(&e.node))
|
(
|
||||||
|
e.node.name.clone(),
|
||||||
|
e.node.modification_time,
|
||||||
|
node_size(&e.node),
|
||||||
|
)
|
||||||
};
|
};
|
||||||
let bytes = self
|
let bytes = self
|
||||||
.rt
|
.rt
|
||||||
@@ -336,7 +350,9 @@ impl Core {
|
|||||||
self.cache
|
self.cache
|
||||||
.store(&uid, mtime, size, &bytes)
|
.store(&uid, mtime, size, &bytes)
|
||||||
.map_err(|e| format!("cache store: {e}"))?;
|
.map_err(|e| format!("cache store: {e}"))?;
|
||||||
self.cache.add_pin(&uid, rel).map_err(|e| format!("pin: {e}"))?;
|
self.cache
|
||||||
|
.add_pin(&uid, rel)
|
||||||
|
.map_err(|e| format!("pin: {e}"))?;
|
||||||
Ok(name)
|
Ok(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,19 +379,28 @@ impl Core {
|
|||||||
Errno::EIO
|
Errno::EIO
|
||||||
})?;
|
})?;
|
||||||
if let Some(bytes) = &bytes {
|
if let Some(bytes) = &bytes {
|
||||||
let _ = self.cache.store_thumbnail(&uid, ttype.as_i32(), mtime, bytes);
|
let _ = self
|
||||||
|
.cache
|
||||||
|
.store_thumbnail(&uid, ttype.as_i32(), mtime, bytes);
|
||||||
}
|
}
|
||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unpin the file at `rel`, evicting its cached content.
|
/// Unpin the file at `rel`, evicting its cached content.
|
||||||
fn unpin(&self, rel: &Path) -> Result<String, String> {
|
fn unpin(&self, rel: &Path) -> Result<String, String> {
|
||||||
let (ino, uid) = self.resolve_path(rel).map_err(|e| format!("resolve path: {e:?}"))?;
|
let (ino, uid) = self
|
||||||
|
.resolve_path(rel)
|
||||||
|
.map_err(|e| format!("resolve path: {e:?}"))?;
|
||||||
let name = {
|
let name = {
|
||||||
let st = self.state.lock().unwrap();
|
let st = self.state.lock().unwrap();
|
||||||
st.entries.get(&ino).map(|e| e.node.name.clone()).unwrap_or_default()
|
st.entries
|
||||||
|
.get(&ino)
|
||||||
|
.map(|e| e.node.name.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
};
|
};
|
||||||
self.cache.remove_pin(&uid).map_err(|e| format!("unpin: {e}"))?;
|
self.cache
|
||||||
|
.remove_pin(&uid)
|
||||||
|
.map_err(|e| format!("unpin: {e}"))?;
|
||||||
Ok(name)
|
Ok(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,8 +416,11 @@ impl Core {
|
|||||||
/// the same lazy remote enumeration `readdir` uses, projected into
|
/// the same lazy remote enumeration `readdir` uses, projected into
|
||||||
/// serializable [`DirEntry`]s (with per-file pin state).
|
/// serializable [`DirEntry`]s (with per-file pin state).
|
||||||
fn list_dir(&self, rel: &Path) -> Result<Vec<DirEntry>, String> {
|
fn list_dir(&self, rel: &Path) -> Result<Vec<DirEntry>, String> {
|
||||||
let (ino, _uid) = self.resolve_path(rel).map_err(|e| format!("resolve path: {e:?}"))?;
|
let (ino, _uid) = self
|
||||||
self.ensure_children(ino).map_err(|e| format!("enumerate: {e:?}"))?;
|
.resolve_path(rel)
|
||||||
|
.map_err(|e| format!("resolve path: {e:?}"))?;
|
||||||
|
self.ensure_children(ino)
|
||||||
|
.map_err(|e| format!("enumerate: {e:?}"))?;
|
||||||
// Snapshot the listing, then drop the lock before touching the on-disk
|
// Snapshot the listing, then drop the lock before touching the on-disk
|
||||||
// pin registry so a slow disk read doesn't block FUSE metadata ops.
|
// pin registry so a slow disk read doesn't block FUSE metadata ops.
|
||||||
let rows: Vec<(String, bool, u64, i64, NodeUid)> = {
|
let rows: Vec<(String, bool, u64, i64, NodeUid)> = {
|
||||||
@@ -480,16 +508,22 @@ impl Core {
|
|||||||
// Batch-fetch only the thumbnails not already cached fresh.
|
// Batch-fetch only the thumbnails not already cached fresh.
|
||||||
let want: Vec<NodeUid> = page
|
let want: Vec<NodeUid> = page
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|it| self.cache.cached_thumbnail_path(&it.uid, ttype, it.capture_time).is_none())
|
.filter(|it| {
|
||||||
|
self.cache
|
||||||
|
.cached_thumbnail_path(&it.uid, ttype, it.capture_time)
|
||||||
|
.is_none()
|
||||||
|
})
|
||||||
.map(|it| it.uid.clone())
|
.map(|it| it.uid.clone())
|
||||||
.collect();
|
.collect();
|
||||||
if !want.is_empty() {
|
if !want.is_empty() {
|
||||||
let cap: HashMap<NodeUid, i64> =
|
let cap: HashMap<NodeUid, i64> = page
|
||||||
page.iter().map(|it| (it.uid.clone(), it.capture_time)).collect();
|
.iter()
|
||||||
match self
|
.map(|it| (it.uid.clone(), it.capture_time))
|
||||||
.rt
|
.collect();
|
||||||
.block_on(self.photos().enumerate_thumbnails(&want, ThumbnailType::Thumbnail))
|
match self.rt.block_on(
|
||||||
{
|
self.photos()
|
||||||
|
.enumerate_thumbnails(&want, ThumbnailType::Thumbnail),
|
||||||
|
) {
|
||||||
Ok(thumbs) => {
|
Ok(thumbs) => {
|
||||||
for ft in thumbs {
|
for ft in thumbs {
|
||||||
if let (Ok(bytes), Some(&tag)) = (ft.result, cap.get(&ft.file_uid)) {
|
if let (Ok(bytes), Some(&tag)) = (ft.result, cap.get(&ft.file_uid)) {
|
||||||
@@ -546,7 +580,9 @@ impl Core {
|
|||||||
/// when a fresh blob already exists). Lets a front-end open the file with
|
/// when a fresh blob already exists). Lets a front-end open the file with
|
||||||
/// the user's default application without pinning it.
|
/// the user's default application without pinning it.
|
||||||
fn open_file(&self, rel: &Path) -> Result<PathBuf, String> {
|
fn open_file(&self, rel: &Path) -> Result<PathBuf, String> {
|
||||||
let (ino, uid) = self.resolve_path(rel).map_err(|e| format!("resolve path: {e:?}"))?;
|
let (ino, uid) = self
|
||||||
|
.resolve_path(rel)
|
||||||
|
.map_err(|e| format!("resolve path: {e:?}"))?;
|
||||||
let (mtime, size) = {
|
let (mtime, size) = {
|
||||||
let st = self.state.lock().unwrap();
|
let st = self.state.lock().unwrap();
|
||||||
let e = st.entries.get(&ino).ok_or("node vanished")?;
|
let e = st.entries.get(&ino).ok_or("node vanished")?;
|
||||||
@@ -593,7 +629,11 @@ impl ProtonFs {
|
|||||||
st.by_uid.insert(root.uid.clone(), ROOT_INO);
|
st.by_uid.insert(root.uid.clone(), ROOT_INO);
|
||||||
st.entries.insert(
|
st.entries.insert(
|
||||||
ROOT_INO,
|
ROOT_INO,
|
||||||
Entry { uid: root.uid.clone(), parent: ROOT_INO, node: root },
|
Entry {
|
||||||
|
uid: root.uid.clone(),
|
||||||
|
parent: ROOT_INO,
|
||||||
|
node: root,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// SAFETY: geteuid/getegid are infallible and have no preconditions.
|
// SAFETY: geteuid/getegid are infallible and have no preconditions.
|
||||||
@@ -682,9 +722,11 @@ fn media_type_for(name: &str) -> &'static str {
|
|||||||
fn node_size(node: &Node) -> u64 {
|
fn node_size(node: &Node) -> u64 {
|
||||||
match &node.kind {
|
match &node.kind {
|
||||||
NodeKind::Folder => 0,
|
NodeKind::Folder => 0,
|
||||||
NodeKind::File { claimed_size, total_size_on_storage, .. } => {
|
NodeKind::File {
|
||||||
claimed_size.unwrap_or(*total_size_on_storage).max(0) as u64
|
claimed_size,
|
||||||
}
|
total_size_on_storage,
|
||||||
|
..
|
||||||
|
} => claimed_size.unwrap_or(*total_size_on_storage).max(0) as u64,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -804,7 +846,13 @@ impl Filesystem for ProtonFs {
|
|||||||
st.next_fh += 1;
|
st.next_fh += 1;
|
||||||
st.handles.insert(
|
st.handles.insert(
|
||||||
fh,
|
fh,
|
||||||
WriteHandle { ino: ino.0, uid, buf: Vec::new(), seeded: false, dirty: false },
|
WriteHandle {
|
||||||
|
ino: ino.0,
|
||||||
|
uid,
|
||||||
|
buf: Vec::new(),
|
||||||
|
seeded: false,
|
||||||
|
dirty: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
reply.opened(FileHandle(fh), FopenFlags::empty());
|
reply.opened(FileHandle(fh), FopenFlags::empty());
|
||||||
}
|
}
|
||||||
@@ -925,10 +973,22 @@ impl Filesystem for ProtonFs {
|
|||||||
st.next_fh += 1;
|
st.next_fh += 1;
|
||||||
st.handles.insert(
|
st.handles.insert(
|
||||||
fh,
|
fh,
|
||||||
WriteHandle { ino, uid: new_uid, buf: Vec::new(), seeded: true, dirty: false },
|
WriteHandle {
|
||||||
|
ino,
|
||||||
|
uid: new_uid,
|
||||||
|
buf: Vec::new(),
|
||||||
|
seeded: true,
|
||||||
|
dirty: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
let attr = self.attr(ino, &st.entries.get(&ino).unwrap().node);
|
let attr = self.attr(ino, &st.entries.get(&ino).unwrap().node);
|
||||||
reply.created(&TTL, &attr, Generation(0), FileHandle(fh), FopenFlags::empty());
|
reply.created(
|
||||||
|
&TTL,
|
||||||
|
&attr,
|
||||||
|
Generation(0),
|
||||||
|
FileHandle(fh),
|
||||||
|
FopenFlags::empty(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write(
|
fn write(
|
||||||
@@ -1001,7 +1061,14 @@ impl Filesystem for ProtonFs {
|
|||||||
st.set_size(ino.0, len);
|
st.set_size(ino.0, len);
|
||||||
len
|
len
|
||||||
};
|
};
|
||||||
debug!(ino = ino.0, fh, offset, len = data.len(), new_len, "buffered write");
|
debug!(
|
||||||
|
ino = ino.0,
|
||||||
|
fh,
|
||||||
|
offset,
|
||||||
|
len = data.len(),
|
||||||
|
new_len,
|
||||||
|
"buffered write"
|
||||||
|
);
|
||||||
reply.written(data.len() as u32);
|
reply.written(data.len() as u32);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1101,14 +1168,28 @@ impl Filesystem for ProtonFs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn flush(&self, _req: &Request, _ino: INodeNo, fh: FileHandle, _lock_owner: LockOwner, reply: ReplyEmpty) {
|
fn flush(
|
||||||
|
&self,
|
||||||
|
_req: &Request,
|
||||||
|
_ino: INodeNo,
|
||||||
|
fh: FileHandle,
|
||||||
|
_lock_owner: LockOwner,
|
||||||
|
reply: ReplyEmpty,
|
||||||
|
) {
|
||||||
match self.core.commit(fh.0) {
|
match self.core.commit(fh.0) {
|
||||||
Ok(()) => reply.ok(),
|
Ok(()) => reply.ok(),
|
||||||
Err(e) => reply.error(e),
|
Err(e) => reply.error(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fsync(&self, _req: &Request, _ino: INodeNo, fh: FileHandle, _datasync: bool, reply: ReplyEmpty) {
|
fn fsync(
|
||||||
|
&self,
|
||||||
|
_req: &Request,
|
||||||
|
_ino: INodeNo,
|
||||||
|
fh: FileHandle,
|
||||||
|
_datasync: bool,
|
||||||
|
reply: ReplyEmpty,
|
||||||
|
) {
|
||||||
match self.core.commit(fh.0) {
|
match self.core.commit(fh.0) {
|
||||||
Ok(()) => reply.ok(),
|
Ok(()) => reply.ok(),
|
||||||
Err(e) => reply.error(e),
|
Err(e) => reply.error(e),
|
||||||
@@ -1170,18 +1251,19 @@ impl Filesystem for ProtonFs {
|
|||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.map(|d| d.as_secs() as i64)
|
.map(|d| d.as_secs() as i64)
|
||||||
.ok();
|
.ok();
|
||||||
let new_uid = match self
|
let new_uid =
|
||||||
.core
|
match self
|
||||||
.rt
|
.core
|
||||||
.block_on(self.core.client.create_folder(&parent_uid, &name, now))
|
.rt
|
||||||
{
|
.block_on(self.core.client.create_folder(&parent_uid, &name, now))
|
||||||
Ok(u) => u,
|
{
|
||||||
Err(e) => {
|
Ok(u) => u,
|
||||||
error!(%parent_uid, name, error = %e, "create folder failed");
|
Err(e) => {
|
||||||
reply.error(Errno::EIO);
|
error!(%parent_uid, name, error = %e, "create folder failed");
|
||||||
return;
|
reply.error(Errno::EIO);
|
||||||
}
|
return;
|
||||||
};
|
}
|
||||||
|
};
|
||||||
let node = match self.core.fetch_node(&new_uid) {
|
let node = match self.core.fetch_node(&new_uid) {
|
||||||
Ok(n) => n,
|
Ok(n) => n,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1340,9 +1422,19 @@ impl Filesystem for ProtonFs {
|
|||||||
/// The cache is authoritative-by-absence: dropping a directory's `children`
|
/// The cache is authoritative-by-absence: dropping a directory's `children`
|
||||||
/// entry forces the next `lookup`/`readdir` to re-enumerate from the remote, so
|
/// entry forces the next `lookup`/`readdir` to re-enumerate from the remote, so
|
||||||
/// most events only need to invalidate listings rather than re-fetch eagerly.
|
/// most events only need to invalidate listings rather than re-fetch eagerly.
|
||||||
fn apply_event(state: &Mutex<State>, content: &ContentCache, notifier: &Notifier, event: &DriveEvent) {
|
fn apply_event(
|
||||||
|
state: &Mutex<State>,
|
||||||
|
content: &ContentCache,
|
||||||
|
notifier: &Notifier,
|
||||||
|
event: &DriveEvent,
|
||||||
|
) {
|
||||||
match event {
|
match event {
|
||||||
DriveEvent::NodeUpdated { node_uid, parent_node_uid, is_trashed, .. } => {
|
DriveEvent::NodeUpdated {
|
||||||
|
node_uid,
|
||||||
|
parent_node_uid,
|
||||||
|
is_trashed,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
let mut st = state.lock().unwrap();
|
let mut st = state.lock().unwrap();
|
||||||
if *is_trashed {
|
if *is_trashed {
|
||||||
// Trashing makes a node vanish from its parent listing.
|
// Trashing makes a node vanish from its parent listing.
|
||||||
@@ -1486,19 +1578,25 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
Ok(rel) => match core.pin(&rel) {
|
Ok(rel) => match core.pin(&rel) {
|
||||||
Ok(name) => CtlResponse::Ok { message: format!("pinned {name}") },
|
Ok(name) => CtlResponse::Ok {
|
||||||
|
message: format!("pinned {name}"),
|
||||||
|
},
|
||||||
Err(e) => CtlResponse::Error { message: e },
|
Err(e) => CtlResponse::Error { message: e },
|
||||||
},
|
},
|
||||||
Err(e) => CtlResponse::Error { message: e },
|
Err(e) => CtlResponse::Error { message: e },
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::Unpin { path }) => match rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::Unpin { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
Ok(rel) => match core.unpin(&rel) {
|
Ok(rel) => match core.unpin(&rel) {
|
||||||
Ok(name) => CtlResponse::Ok { message: format!("unpinned {name}") },
|
Ok(name) => CtlResponse::Ok {
|
||||||
|
message: format!("unpinned {name}"),
|
||||||
|
},
|
||||||
Err(e) => CtlResponse::Error { message: e },
|
Err(e) => CtlResponse::Error { message: e },
|
||||||
},
|
},
|
||||||
Err(e) => CtlResponse::Error { message: e },
|
Err(e) => CtlResponse::Error { message: e },
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::ListPins) => CtlResponse::Pins { pins: core.cache.list_pins() },
|
Ok(CtlRequest::ListPins) => CtlResponse::Pins {
|
||||||
|
pins: core.cache.list_pins(),
|
||||||
|
},
|
||||||
Ok(CtlRequest::ListDir { path }) => match rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::ListDir { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
Ok(rel) => match core.list_dir(&rel) {
|
Ok(rel) => match core.list_dir(&rel) {
|
||||||
Ok(entries) => CtlResponse::Entries { entries },
|
Ok(entries) => CtlResponse::Entries { entries },
|
||||||
@@ -1508,26 +1606,40 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Ok(CtlRequest::PhotosTimeline { offset, limit }) => {
|
Ok(CtlRequest::PhotosTimeline { offset, limit }) => {
|
||||||
match core.photos_timeline(offset, limit) {
|
match core.photos_timeline(offset, limit) {
|
||||||
Ok(Some(items)) => CtlResponse::Photos { available: true, items },
|
Ok(Some(items)) => CtlResponse::Photos {
|
||||||
Ok(None) => CtlResponse::Photos { available: false, items: Vec::new() },
|
available: true,
|
||||||
|
items,
|
||||||
|
},
|
||||||
|
Ok(None) => CtlResponse::Photos {
|
||||||
|
available: false,
|
||||||
|
items: Vec::new(),
|
||||||
|
},
|
||||||
Err(e) => CtlResponse::Error { message: e },
|
Err(e) => CtlResponse::Error { message: e },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(CtlRequest::OpenPhoto { uid }) => match parse_uid(&uid) {
|
Ok(CtlRequest::OpenPhoto { uid }) => match parse_uid(&uid) {
|
||||||
Some(u) => match core.open_photo(&u) {
|
Some(u) => match core.open_photo(&u) {
|
||||||
Ok(p) => CtlResponse::FilePath { path: p.display().to_string() },
|
Ok(p) => CtlResponse::FilePath {
|
||||||
|
path: p.display().to_string(),
|
||||||
|
},
|
||||||
Err(e) => CtlResponse::Error { message: e },
|
Err(e) => CtlResponse::Error { message: e },
|
||||||
},
|
},
|
||||||
None => CtlResponse::Error { message: format!("bad uid: {uid}") },
|
None => CtlResponse::Error {
|
||||||
|
message: format!("bad uid: {uid}"),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::OpenFile { path }) => match rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::OpenFile { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
Ok(rel) => match core.open_file(&rel) {
|
Ok(rel) => match core.open_file(&rel) {
|
||||||
Ok(p) => CtlResponse::FilePath { path: p.display().to_string() },
|
Ok(p) => CtlResponse::FilePath {
|
||||||
|
path: p.display().to_string(),
|
||||||
|
},
|
||||||
Err(e) => CtlResponse::Error { message: e },
|
Err(e) => CtlResponse::Error { message: e },
|
||||||
},
|
},
|
||||||
Err(e) => CtlResponse::Error { message: e },
|
Err(e) => CtlResponse::Error { message: e },
|
||||||
},
|
},
|
||||||
Err(e) => CtlResponse::Error { message: format!("bad request: {e}") },
|
Err(e) => CtlResponse::Error {
|
||||||
|
message: format!("bad request: {e}"),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
let mut out = match serde_json::to_vec(&response) {
|
let mut out = match serde_json::to_vec(&response) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
@@ -1618,7 +1730,9 @@ pub fn mount(
|
|||||||
// `mount`'s blocking contract.
|
// `mount`'s blocking contract.
|
||||||
let session = Session::new(fs, mountpoint, &config)?.spawn()?;
|
let session = Session::new(fs, mountpoint, &config)?.spawn()?;
|
||||||
let notifier = session.notifier();
|
let notifier = session.notifier();
|
||||||
rt.spawn(run_event_sync(client, scope, core.state, core.cache, notifier));
|
rt.spawn(run_event_sync(
|
||||||
|
client, scope, core.state, core.cache, notifier,
|
||||||
|
));
|
||||||
|
|
||||||
let result = session.join();
|
let result = session.join();
|
||||||
let _ = std::fs::remove_file(control_socket);
|
let _ = std::fs::remove_file(control_socket);
|
||||||
|
|||||||
+53
-22
@@ -87,17 +87,27 @@ struct Ui {
|
|||||||
fn main() -> glib::ExitCode {
|
fn main() -> glib::ExitCode {
|
||||||
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 app = adw::Application::builder().application_id(APP_ID).build();
|
let app = adw::Application::builder().application_id(APP_ID).build();
|
||||||
app.connect_startup(|_| load_proton_theme());
|
app.connect_startup(|_| {
|
||||||
|
load_proton_theme();
|
||||||
|
spawn_tray();
|
||||||
|
});
|
||||||
app.connect_activate(build_window);
|
app.connect_activate(build_window);
|
||||||
app.run()
|
app.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawn the tray icon process in the background.
|
||||||
|
fn spawn_tray() {
|
||||||
|
match Command::new("pdfs-tray").spawn() {
|
||||||
|
Ok(_) => tracing::info!("spawned `pdfs-tray`"),
|
||||||
|
Err(e) => tracing::error!("failed to spawn `pdfs-tray`: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Install a CSS provider that overrides libadwaita's accent colour with Proton
|
/// Install a CSS provider that overrides libadwaita's accent colour with Proton
|
||||||
/// purple, app-wide. Named-colour overrides recolour the stock widgets (switch,
|
/// purple, app-wide. Named-colour overrides recolour the stock widgets (switch,
|
||||||
/// buttons, progress fill) without per-widget styling.
|
/// buttons, progress fill) without per-widget styling.
|
||||||
@@ -141,7 +151,12 @@ fn build_window(app: &adw::Application) {
|
|||||||
// Login has no title, so it never appears in the switcher; the three signed-in
|
// Login has no title, so it never appears in the switcher; the three signed-in
|
||||||
// pages are titled + iconed so the bottom switcher lists them.
|
// pages are titled + iconed so the bottom switcher lists them.
|
||||||
stack.add_named(&login_page, Some("login"));
|
stack.add_named(&login_page, Some("login"));
|
||||||
stack.add_titled_with_icon(&main_page, Some("main"), "Account", "avatar-default-symbolic");
|
stack.add_titled_with_icon(
|
||||||
|
&main_page,
|
||||||
|
Some("main"),
|
||||||
|
"Account",
|
||||||
|
"avatar-default-symbolic",
|
||||||
|
);
|
||||||
stack.add_titled_with_icon(&browser_page, Some("browser"), "Files", "folder-symbolic");
|
stack.add_titled_with_icon(&browser_page, Some("browser"), "Files", "folder-symbolic");
|
||||||
stack.add_titled_with_icon(
|
stack.add_titled_with_icon(
|
||||||
&gallery_page,
|
&gallery_page,
|
||||||
@@ -353,9 +368,7 @@ fn build_main_page() -> (
|
|||||||
storage_box.set_margin_top(6);
|
storage_box.set_margin_top(6);
|
||||||
storage_box.set_margin_bottom(6);
|
storage_box.set_margin_bottom(6);
|
||||||
let cache_bar = gtk4::ProgressBar::new();
|
let cache_bar = gtk4::ProgressBar::new();
|
||||||
let cache_label = gtk4::Label::builder()
|
let cache_label = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
||||||
.halign(gtk4::Align::Start)
|
|
||||||
.build();
|
|
||||||
cache_label.add_css_class("dim-label");
|
cache_label.add_css_class("dim-label");
|
||||||
storage_box.append(&cache_bar);
|
storage_box.append(&cache_bar);
|
||||||
storage_box.append(&cache_label);
|
storage_box.append(&cache_label);
|
||||||
@@ -377,7 +390,10 @@ fn build_main_page() -> (
|
|||||||
inner.append(&storage_group);
|
inner.append(&storage_group);
|
||||||
inner.append(&pins_group);
|
inner.append(&pins_group);
|
||||||
|
|
||||||
let clamp = adw::Clamp::builder().maximum_size(560).child(&inner).build();
|
let clamp = adw::Clamp::builder()
|
||||||
|
.maximum_size(560)
|
||||||
|
.child(&inner)
|
||||||
|
.build();
|
||||||
let scroll = gtk4::ScrolledWindow::builder().child(&clamp).build();
|
let scroll = gtk4::ScrolledWindow::builder().child(&clamp).build();
|
||||||
|
|
||||||
(
|
(
|
||||||
@@ -413,7 +429,10 @@ fn wire_login(ui: &Rc<Ui>) {
|
|||||||
|
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let result = rx.recv().await.unwrap_or_else(|_| Err("login cancelled".into()));
|
let result = rx
|
||||||
|
.recv()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| Err("login cancelled".into()));
|
||||||
ui.login_button.set_sensitive(true);
|
ui.login_button.set_sensitive(true);
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
@@ -624,7 +643,10 @@ fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin]) {
|
|||||||
/// channel that yields the [`Response`] once. Browser/gallery requests reach the
|
/// channel that yields the [`Response`] once. Browser/gallery requests reach the
|
||||||
/// network through the daemon, so they must not block the GTK main loop the way
|
/// network through the daemon, so they must not block the GTK main loop the way
|
||||||
/// the cheap [`Request::Status`] poll in [`refresh`] can.
|
/// the cheap [`Request::Status`] poll in [`refresh`] can.
|
||||||
fn spawn_request(socket: PathBuf, req: Request) -> async_channel::Receiver<Result<Response, String>> {
|
fn spawn_request(
|
||||||
|
socket: PathBuf,
|
||||||
|
req: Request,
|
||||||
|
) -> async_channel::Receiver<Result<Response, String>> {
|
||||||
let (tx, rx) = async_channel::bounded(1);
|
let (tx, rx) = async_channel::bounded(1);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let result = send(&socket, &req).map_err(|e| e.to_string());
|
let result = send(&socket, &req).map_err(|e| e.to_string());
|
||||||
@@ -703,7 +725,10 @@ fn build_browser_page() -> (
|
|||||||
.max_columns(10)
|
.max_columns(10)
|
||||||
.build();
|
.build();
|
||||||
grid.add_css_class("file-grid");
|
grid.add_css_class("file-grid");
|
||||||
let grid_scroll = gtk4::ScrolledWindow::builder().vexpand(true).child(&grid).build();
|
let grid_scroll = gtk4::ScrolledWindow::builder()
|
||||||
|
.vexpand(true)
|
||||||
|
.child(&grid)
|
||||||
|
.build();
|
||||||
|
|
||||||
// Column list.
|
// Column list.
|
||||||
let column_view = gtk4::ColumnView::builder()
|
let column_view = gtk4::ColumnView::builder()
|
||||||
@@ -742,7 +767,10 @@ fn build_browser_page() -> (
|
|||||||
inner.append(&status);
|
inner.append(&status);
|
||||||
inner.append(&view_stack);
|
inner.append(&view_stack);
|
||||||
|
|
||||||
(inner.upcast(), (model, back, crumb, status, grid, column_view))
|
(
|
||||||
|
inner.upcast(),
|
||||||
|
(model, back, crumb, status, grid, column_view),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install the entry factories, columns, activation handlers and the back
|
/// Install the entry factories, columns, activation handlers and the back
|
||||||
@@ -975,7 +1003,9 @@ fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
match rx.recv().await {
|
match rx.recv().await {
|
||||||
Ok(Ok(Response::FilePath { path })) => open_path(&path),
|
Ok(Ok(Response::FilePath { path })) => open_path(&path),
|
||||||
Ok(Ok(Response::Error { message })) => tracing::error!("open file failed: {message}"),
|
Ok(Ok(Response::Error { message })) => {
|
||||||
|
tracing::error!("open file failed: {message}")
|
||||||
|
}
|
||||||
_ => tracing::error!("open file request failed"),
|
_ => tracing::error!("open file request failed"),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1052,7 +1082,10 @@ fn icon_base_for(entry: &DirEntry) -> &'static str {
|
|||||||
/// Format an epoch-seconds modification time as a short local date.
|
/// Format an epoch-seconds modification time as a short local date.
|
||||||
fn format_modified(secs: i64) -> String {
|
fn format_modified(secs: i64) -> String {
|
||||||
match glib::DateTime::from_unix_local(secs) {
|
match glib::DateTime::from_unix_local(secs) {
|
||||||
Ok(dt) => dt.format("%-d %b %Y").map(|s| s.to_string()).unwrap_or_default(),
|
Ok(dt) => dt
|
||||||
|
.format("%-d %b %Y")
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
Err(_) => String::new(),
|
Err(_) => String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1075,9 +1108,7 @@ fn load_browser(ui: &Rc<Ui>) {
|
|||||||
Ok(Ok(Response::Entries { entries })) => repaint_browser(&ui, &entries),
|
Ok(Ok(Response::Entries { entries })) => repaint_browser(&ui, &entries),
|
||||||
Ok(Ok(Response::Error { message })) => browser_message(&ui, &message),
|
Ok(Ok(Response::Error { message })) => browser_message(&ui, &message),
|
||||||
Ok(Ok(_)) => browser_message(&ui, "Unexpected reply from daemon."),
|
Ok(Ok(_)) => browser_message(&ui, "Unexpected reply from daemon."),
|
||||||
Ok(Err(_)) | Err(_) => {
|
Ok(Err(_)) | Err(_) => browser_message(&ui, "Mount Proton Drive to browse your files."),
|
||||||
browser_message(&ui, "Mount Proton Drive to browse your files.")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1196,7 +1227,9 @@ fn wire_gallery(ui: &Rc<Ui>, grid: >k4::GridView) {
|
|||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
match rx.recv().await {
|
match rx.recv().await {
|
||||||
Ok(Ok(Response::FilePath { path })) => open_path(&path),
|
Ok(Ok(Response::FilePath { path })) => open_path(&path),
|
||||||
Ok(Ok(Response::Error { message })) => tracing::error!("open photo failed: {message}"),
|
Ok(Ok(Response::Error { message })) => {
|
||||||
|
tracing::error!("open photo failed: {message}")
|
||||||
|
}
|
||||||
_ => tracing::error!("open photo request failed"),
|
_ => tracing::error!("open photo request failed"),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1247,9 +1280,7 @@ fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
|||||||
}
|
}
|
||||||
Ok(Ok(Response::Error { message })) => gallery_message(&ui, &message),
|
Ok(Ok(Response::Error { message })) => gallery_message(&ui, &message),
|
||||||
Ok(Ok(_)) => gallery_message(&ui, "Unexpected reply from daemon."),
|
Ok(Ok(_)) => gallery_message(&ui, "Unexpected reply from daemon."),
|
||||||
Ok(Err(_)) | Err(_) => {
|
Ok(Err(_)) | Err(_) => gallery_message(&ui, "Mount Proton Drive to see your photos."),
|
||||||
gallery_message(&ui, "Mount Proton Drive to see your photos.")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1304,4 +1335,4 @@ fn human_bytes(bytes: u64) -> String {
|
|||||||
} else {
|
} else {
|
||||||
format!("{value:.1} {}", UNITS[unit])
|
format!("{value:.1} {}", UNITS[unit])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,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),
|
||||||
@@ -174,12 +176,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 |
+3
-1
@@ -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,6 +56,8 @@ 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"
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
Reference in new issue
Block a user