mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 08: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]]
|
||||
name = "pdfs-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap 4.6.1",
|
||||
@@ -2787,7 +2787,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-core"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"directories",
|
||||
"keyring",
|
||||
@@ -2803,7 +2803,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-fuse"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"fuser",
|
||||
"libc",
|
||||
@@ -2816,7 +2816,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-gui"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
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.
|
||||
- **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.
|
||||
- **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};
|
||||
|
||||
#[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 {
|
||||
#[command(subcommand)]
|
||||
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.
|
||||
let get_totp = || -> pdfs_core::Result<String> {
|
||||
prompt_line("2FA code")
|
||||
.map_err(|e| pdfs_core::Error::Other(format!("read 2FA code: {e}")))
|
||||
prompt_line("2FA code").map_err(|e| pdfs_core::Error::Other(format!("read 2FA code: {e}")))
|
||||
};
|
||||
|
||||
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.
|
||||
match control_request(CtlRequest::Status) {
|
||||
Ok(CtlResponse::Status { mountpoint, pinned, .. }) => {
|
||||
Ok(CtlResponse::Status {
|
||||
mountpoint, pinned, ..
|
||||
}) => {
|
||||
println!("Mounted at {mountpoint} ({pinned} pinned)");
|
||||
}
|
||||
Ok(other) => println!("Mount: unexpected response {other:?}"),
|
||||
@@ -209,8 +214,14 @@ fn cmd_mount(mountpoint: Option<PathBuf>) -> Result<()> {
|
||||
});
|
||||
|
||||
let handle = rt.handle().clone();
|
||||
let result =
|
||||
pdfs_fuse::mount(client, handle, &mountpoint, cache, &control_socket, username);
|
||||
let result = pdfs_fuse::mount(
|
||||
client,
|
||||
handle,
|
||||
&mountpoint,
|
||||
cache,
|
||||
&control_socket,
|
||||
username,
|
||||
);
|
||||
|
||||
// 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
|
||||
@@ -224,7 +235,9 @@ fn cmd_mount(mountpoint: Option<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::Error { message } => bail!("{message}"),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
@@ -233,7 +246,9 @@ fn cmd_pin(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::Error { message } => bail!("{message}"),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
@@ -277,7 +292,9 @@ fn cmd_ls(path: Option<PathBuf>) -> Result<()> {
|
||||
|
||||
fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
|
||||
match control_request(CtlRequest::PhotosTimeline { offset, limit })? {
|
||||
CtlResponse::Photos { available: false, .. } => {
|
||||
CtlResponse::Photos {
|
||||
available: false, ..
|
||||
} => {
|
||||
println!("This account has no photos volume.")
|
||||
}
|
||||
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.
|
||||
fn path_arg(path: &Path) -> Result<String> {
|
||||
let p = if path.is_absolute() {
|
||||
std::fs::canonicalize(path)
|
||||
.with_context(|| format!("resolve {}", path.display()))?
|
||||
std::fs::canonicalize(path).with_context(|| format!("resolve {}", path.display()))?
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ use proton_sdk::config::ProtonClientConfiguration;
|
||||
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
||||
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};
|
||||
|
||||
/// Fixed keyring account name for the single stored session blob.
|
||||
@@ -53,7 +53,14 @@ impl StoredSession {
|
||||
}
|
||||
|
||||
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> {
|
||||
|
||||
@@ -81,7 +81,12 @@ impl ContentCache {
|
||||
if let Some(parent) = pins_path.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
|
||||
@@ -192,7 +197,8 @@ impl ContentCache {
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -267,8 +273,12 @@ impl ContentCache {
|
||||
return;
|
||||
}
|
||||
// Pinned blobs (by cache key) are exempt from eviction.
|
||||
let pinned: HashSet<String> =
|
||||
self.load_pins().pins.keys().map(|uid| Self::key_str(uid)).collect();
|
||||
let pinned: HashSet<String> = self
|
||||
.load_pins()
|
||||
.pins
|
||||
.keys()
|
||||
.map(|uid| Self::key_str(uid))
|
||||
.collect();
|
||||
|
||||
// Scan blobs (the `<key>` files; skip `.meta`/`.tmp` siblings), record
|
||||
// size + last-access (mtime) for each.
|
||||
@@ -336,7 +346,10 @@ impl ContentCache {
|
||||
let key = uid.to_string();
|
||||
file.pins.insert(
|
||||
key.clone(),
|
||||
Pin { uid: key, path: path.display().to_string() },
|
||||
Pin {
|
||||
uid: key,
|
||||
path: path.display().to_string(),
|
||||
},
|
||||
);
|
||||
self.save_pins(&file)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
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`].
|
||||
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.
|
||||
pub struct AppDirs {
|
||||
dirs: ProjectDirs,
|
||||
@@ -30,6 +47,28 @@ impl AppDirs {
|
||||
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.
|
||||
pub fn state_dir(&self) -> PathBuf {
|
||||
// `state_dir` is Linux-only in `directories`; fall back to data dir.
|
||||
@@ -59,6 +98,11 @@ impl AppDirs {
|
||||
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.
|
||||
pub fn default_mountpoint(&self) -> PathBuf {
|
||||
directories::UserDirs::new()
|
||||
@@ -70,6 +114,31 @@ impl AppDirs {
|
||||
pub fn ensure(&self) -> Result<()> {
|
||||
std::fs::create_dir_all(self.state_dir())?;
|
||||
std::fs::create_dir_all(self.cache_dir())?;
|
||||
let _ = std::fs::create_dir_all(self.dirs.config_dir());
|
||||
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> },
|
||||
/// A page of the photos timeline. `available` is false when the account
|
||||
/// 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).
|
||||
FilePath { path: String },
|
||||
/// The request failed.
|
||||
|
||||
+176
-62
@@ -32,13 +32,13 @@ use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
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::{
|
||||
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::control::{DirEntry, PhotoItem, Request as CtlRequest, Response as CtlResponse};
|
||||
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId};
|
||||
@@ -120,8 +120,14 @@ impl State {
|
||||
let ino = self.next_ino;
|
||||
self.next_ino += 1;
|
||||
self.by_uid.insert(node.uid.clone(), ino);
|
||||
self.entries
|
||||
.insert(ino, Entry { uid: node.uid.clone(), parent, node });
|
||||
self.entries.insert(
|
||||
ino,
|
||||
Entry {
|
||||
uid: node.uid.clone(),
|
||||
parent,
|
||||
node,
|
||||
},
|
||||
);
|
||||
ino
|
||||
}
|
||||
|
||||
@@ -252,14 +258,16 @@ impl Core {
|
||||
let mut ino = ROOT_INO;
|
||||
let mut uid = {
|
||||
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() {
|
||||
match comp {
|
||||
Component::RootDir | Component::CurDir => continue,
|
||||
Component::Normal(name) => {
|
||||
let (child_ino, child_uid) =
|
||||
self.lookup_child(ino, &name.to_string_lossy())?;
|
||||
let (child_ino, child_uid) = self.lookup_child(ino, &name.to_string_lossy())?;
|
||||
ino = child_ino;
|
||||
uid = child_uid;
|
||||
}
|
||||
@@ -320,14 +328,20 @@ impl Core {
|
||||
/// into the content cache and record it in the pin registry. Returns the
|
||||
/// resolved node name on success.
|
||||
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 st = self.state.lock().unwrap();
|
||||
let e = st.entries.get(&ino).ok_or("node vanished")?;
|
||||
if !e.node.is_file() {
|
||||
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
|
||||
.rt
|
||||
@@ -336,7 +350,9 @@ impl Core {
|
||||
self.cache
|
||||
.store(&uid, mtime, size, &bytes)
|
||||
.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)
|
||||
}
|
||||
|
||||
@@ -363,19 +379,28 @@ impl Core {
|
||||
Errno::EIO
|
||||
})?;
|
||||
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)
|
||||
}
|
||||
|
||||
/// Unpin the file at `rel`, evicting its cached content.
|
||||
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 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)
|
||||
}
|
||||
|
||||
@@ -391,8 +416,11 @@ impl Core {
|
||||
/// the same lazy remote enumeration `readdir` uses, projected into
|
||||
/// serializable [`DirEntry`]s (with per-file pin state).
|
||||
fn list_dir(&self, rel: &Path) -> Result<Vec<DirEntry>, String> {
|
||||
let (ino, _uid) = self.resolve_path(rel).map_err(|e| format!("resolve path: {e:?}"))?;
|
||||
self.ensure_children(ino).map_err(|e| format!("enumerate: {e:?}"))?;
|
||||
let (ino, _uid) = self
|
||||
.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
|
||||
// pin registry so a slow disk read doesn't block FUSE metadata ops.
|
||||
let rows: Vec<(String, bool, u64, i64, NodeUid)> = {
|
||||
@@ -480,16 +508,22 @@ impl Core {
|
||||
// Batch-fetch only the thumbnails not already cached fresh.
|
||||
let want: Vec<NodeUid> = page
|
||||
.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())
|
||||
.collect();
|
||||
if !want.is_empty() {
|
||||
let cap: HashMap<NodeUid, i64> =
|
||||
page.iter().map(|it| (it.uid.clone(), it.capture_time)).collect();
|
||||
match self
|
||||
.rt
|
||||
.block_on(self.photos().enumerate_thumbnails(&want, ThumbnailType::Thumbnail))
|
||||
{
|
||||
let cap: HashMap<NodeUid, i64> = page
|
||||
.iter()
|
||||
.map(|it| (it.uid.clone(), it.capture_time))
|
||||
.collect();
|
||||
match self.rt.block_on(
|
||||
self.photos()
|
||||
.enumerate_thumbnails(&want, ThumbnailType::Thumbnail),
|
||||
) {
|
||||
Ok(thumbs) => {
|
||||
for ft in thumbs {
|
||||
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
|
||||
/// the user's default application without pinning it.
|
||||
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 st = self.state.lock().unwrap();
|
||||
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.entries.insert(
|
||||
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.
|
||||
@@ -682,9 +722,11 @@ fn media_type_for(name: &str) -> &'static str {
|
||||
fn node_size(node: &Node) -> u64 {
|
||||
match &node.kind {
|
||||
NodeKind::Folder => 0,
|
||||
NodeKind::File { claimed_size, total_size_on_storage, .. } => {
|
||||
claimed_size.unwrap_or(*total_size_on_storage).max(0) as u64
|
||||
}
|
||||
NodeKind::File {
|
||||
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.handles.insert(
|
||||
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());
|
||||
}
|
||||
@@ -925,10 +973,22 @@ impl Filesystem for ProtonFs {
|
||||
st.next_fh += 1;
|
||||
st.handles.insert(
|
||||
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);
|
||||
reply.created(&TTL, &attr, Generation(0), FileHandle(fh), FopenFlags::empty());
|
||||
reply.created(
|
||||
&TTL,
|
||||
&attr,
|
||||
Generation(0),
|
||||
FileHandle(fh),
|
||||
FopenFlags::empty(),
|
||||
);
|
||||
}
|
||||
|
||||
fn write(
|
||||
@@ -1001,7 +1061,14 @@ impl Filesystem for ProtonFs {
|
||||
st.set_size(ino.0, 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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
Ok(()) => reply.ok(),
|
||||
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) {
|
||||
Ok(()) => reply.ok(),
|
||||
Err(e) => reply.error(e),
|
||||
@@ -1170,18 +1251,19 @@ impl Filesystem for ProtonFs {
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.ok();
|
||||
let new_uid = match self
|
||||
.core
|
||||
.rt
|
||||
.block_on(self.core.client.create_folder(&parent_uid, &name, now))
|
||||
{
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
error!(%parent_uid, name, error = %e, "create folder failed");
|
||||
reply.error(Errno::EIO);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let new_uid =
|
||||
match self
|
||||
.core
|
||||
.rt
|
||||
.block_on(self.core.client.create_folder(&parent_uid, &name, now))
|
||||
{
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
error!(%parent_uid, name, error = %e, "create folder failed");
|
||||
reply.error(Errno::EIO);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let node = match self.core.fetch_node(&new_uid) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
@@ -1340,9 +1422,19 @@ impl Filesystem for ProtonFs {
|
||||
/// The cache is authoritative-by-absence: dropping a directory's `children`
|
||||
/// 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.
|
||||
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 {
|
||||
DriveEvent::NodeUpdated { node_uid, parent_node_uid, is_trashed, .. } => {
|
||||
DriveEvent::NodeUpdated {
|
||||
node_uid,
|
||||
parent_node_uid,
|
||||
is_trashed,
|
||||
..
|
||||
} => {
|
||||
let mut st = state.lock().unwrap();
|
||||
if *is_trashed {
|
||||
// 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(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 },
|
||||
},
|
||||
Ok(CtlRequest::Unpin { path }) => match rel_to_mount(mountpoint, &path) {
|
||||
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 },
|
||||
},
|
||||
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(rel) => match core.list_dir(&rel) {
|
||||
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 }) => {
|
||||
match core.photos_timeline(offset, limit) {
|
||||
Ok(Some(items)) => CtlResponse::Photos { available: true, items },
|
||||
Ok(None) => CtlResponse::Photos { available: false, items: Vec::new() },
|
||||
Ok(Some(items)) => CtlResponse::Photos {
|
||||
available: true,
|
||||
items,
|
||||
},
|
||||
Ok(None) => CtlResponse::Photos {
|
||||
available: false,
|
||||
items: Vec::new(),
|
||||
},
|
||||
Err(e) => CtlResponse::Error { message: e },
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::OpenPhoto { uid }) => match parse_uid(&uid) {
|
||||
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 },
|
||||
},
|
||||
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(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: format!("bad request: {e}") },
|
||||
Err(e) => CtlResponse::Error {
|
||||
message: format!("bad request: {e}"),
|
||||
},
|
||||
};
|
||||
let mut out = match serde_json::to_vec(&response) {
|
||||
Ok(v) => v,
|
||||
@@ -1618,7 +1730,9 @@ pub fn mount(
|
||||
// `mount`'s blocking contract.
|
||||
let session = Session::new(fs, mountpoint, &config)?.spawn()?;
|
||||
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 _ = std::fs::remove_file(control_socket);
|
||||
|
||||
+53
-22
@@ -87,17 +87,27 @@ struct Ui {
|
||||
fn main() -> glib::ExitCode {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
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.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
|
||||
/// purple, app-wide. Named-colour overrides recolour the stock widgets (switch,
|
||||
/// 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
|
||||
// pages are titled + iconed so the bottom switcher lists them.
|
||||
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(
|
||||
&gallery_page,
|
||||
@@ -353,9 +368,7 @@ fn build_main_page() -> (
|
||||
storage_box.set_margin_top(6);
|
||||
storage_box.set_margin_bottom(6);
|
||||
let cache_bar = gtk4::ProgressBar::new();
|
||||
let cache_label = gtk4::Label::builder()
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
let cache_label = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
||||
cache_label.add_css_class("dim-label");
|
||||
storage_box.append(&cache_bar);
|
||||
storage_box.append(&cache_label);
|
||||
@@ -377,7 +390,10 @@ fn build_main_page() -> (
|
||||
inner.append(&storage_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();
|
||||
|
||||
(
|
||||
@@ -413,7 +429,10 @@ fn wire_login(ui: &Rc<Ui>) {
|
||||
|
||||
let ui = ui.clone();
|
||||
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);
|
||||
match result {
|
||||
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
|
||||
/// network through the daemon, so they must not block the GTK main loop the way
|
||||
/// 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);
|
||||
std::thread::spawn(move || {
|
||||
let result = send(&socket, &req).map_err(|e| e.to_string());
|
||||
@@ -703,7 +725,10 @@ fn build_browser_page() -> (
|
||||
.max_columns(10)
|
||||
.build();
|
||||
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.
|
||||
let column_view = gtk4::ColumnView::builder()
|
||||
@@ -742,7 +767,10 @@ fn build_browser_page() -> (
|
||||
inner.append(&status);
|
||||
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
|
||||
@@ -975,7 +1003,9 @@ fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
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"),
|
||||
}
|
||||
});
|
||||
@@ -1052,7 +1082,10 @@ fn icon_base_for(entry: &DirEntry) -> &'static str {
|
||||
/// Format an epoch-seconds modification time as a short local date.
|
||||
fn format_modified(secs: i64) -> String {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -1075,9 +1108,7 @@ fn load_browser(ui: &Rc<Ui>) {
|
||||
Ok(Ok(Response::Entries { entries })) => repaint_browser(&ui, &entries),
|
||||
Ok(Ok(Response::Error { message })) => browser_message(&ui, &message),
|
||||
Ok(Ok(_)) => browser_message(&ui, "Unexpected reply from daemon."),
|
||||
Ok(Err(_)) | Err(_) => {
|
||||
browser_message(&ui, "Mount Proton Drive to browse your files.")
|
||||
}
|
||||
Ok(Err(_)) | Err(_) => 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 {
|
||||
match rx.recv().await {
|
||||
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"),
|
||||
}
|
||||
});
|
||||
@@ -1247,9 +1280,7 @@ fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
}
|
||||
Ok(Ok(Response::Error { message })) => gallery_message(&ui, &message),
|
||||
Ok(Ok(_)) => gallery_message(&ui, "Unexpected reply from daemon."),
|
||||
Ok(Err(_)) | Err(_) => {
|
||||
gallery_message(&ui, "Mount Proton Drive to see your photos.")
|
||||
}
|
||||
Ok(Err(_)) | Err(_) => gallery_message(&ui, "Mount Proton Drive to see your photos."),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1304,4 +1335,4 @@ fn human_bytes(bytes: u64) -> String {
|
||||
} else {
|
||||
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.
|
||||
fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||
match send(socket, &Request::Status) {
|
||||
Ok(Response::Status { mountpoint, pinned, .. }) => DriveState {
|
||||
Ok(Response::Status {
|
||||
mountpoint, pinned, ..
|
||||
}) => DriveState {
|
||||
line: format!("Mounted at {mountpoint} ({pinned} pinned)"),
|
||||
mounted: true,
|
||||
mountpoint: PathBuf::from(mountpoint),
|
||||
@@ -174,12 +176,22 @@ impl Tray for DriveTray {
|
||||
fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
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 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.
|
||||
optdepends=('gnome-keyring: credential storage via Secret Service'
|
||||
'xdg-utils: "Open folder" action from the tray')
|
||||
options=('!lto')
|
||||
options=('!lto' '!debug')
|
||||
|
||||
# Built in place from the checkout; nothing to fetch or checksum.
|
||||
source=()
|
||||
@@ -56,6 +56,8 @@ package() {
|
||||
|
||||
install -Dm644 "packaging/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" \
|
||||
"$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 -->
|
||||
<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) -->
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 733 B After Width: | Height: | Size: 718 B |
Reference in new issue
Block a user