Compare commits

..
5 Commits
Author SHA1 Message Date
nvrl ca30dceae7 version bump to 0.1.2 2026-06-30 05:00:51 +02:00
nvrl 13d45ce6f5 clippy 2026-06-30 04:59:15 +02:00
nvrl f31633cb3a fmt 2026-06-30 04:58:40 +02:00
nvrl 86fa8aef03 updated app name + fixed missing icon in pkgbuild 2026-06-30 04:58:03 +02:00
nvrl aa70077ad0 added images 2026-06-30 04:07:19 +02:00
18 changed files with 404 additions and 113 deletions

No files matched your search

Generated
+4 -4
View File
@@ -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
View File
@@ -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>"]
+13
View File
@@ -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 |
| :---: | :---: |
| ![Login Screen](images/login.png) | ![Account Overview](images/account.png) |
| Files (Grid View) | Files (List View) |
| :---: | :---: |
| ![Files Grid](images/files_grid.png) | ![Files List](images/files_list.png) |
--- ---
+27 -11
View File
@@ -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()
}; };
+9 -2
View File
@@ -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> {
+18 -5
View File
@@ -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)
} }
+69
View 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);
}
}
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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: &gtk4::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])
} }
} }
+15 -3
View File
@@ -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

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

+3 -1
View File
@@ -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
+1 -1
View File
@@ -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