refactor, fixed monolithic

This commit is contained in:
nvrl committed 2026-07-18 18:33:14 +02:00
1 parent 232b3b5348
commit 7766628098
28 files changed
+9242 -8854

No files matched your search

+42 -28
View File
@@ -11,6 +11,7 @@ use pdfs_core::auth;
use pdfs_core::cache::ContentCache;
use pdfs_core::config::AppDirs;
use pdfs_core::control::{
ErrorKind,
RefreshScope, Request as CtlRequest, Response as CtlResponse, ShareEntryKind, SyncPhase,
pending_summary,
};
@@ -351,6 +352,19 @@ enum BookmarkCmd {
Rm { token: String },
}
/// Render a daemon failure for a terminal.
///
/// Offline is the one class worth rewording: the daemon's prose names whichever
/// call happened to hit the network, which tells someone at a shell nothing they
/// can act on. Every other class already says something specific, so it is
/// printed as sent.
fn cli_error(kind: ErrorKind, message: &str) -> String {
match kind {
ErrorKind::Offline => "offline: the Proton Drive API is unreachable".to_string(),
_ => message.to_string(),
}
}
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
@@ -429,7 +443,7 @@ fn cmd_devices(action: DeviceCmd) -> Result<()> {
);
}
}
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
},
DeviceCmd::Rename { uid, name } => {
@@ -489,7 +503,7 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
}
}
}
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
},
SyncCmd::Rm { id, delete_remote } => {
@@ -545,7 +559,7 @@ fn cmd_members(path: PathBuf) -> Result<()> {
None => println!("public link: none"),
}
}
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
@@ -584,7 +598,7 @@ fn cmd_public_link(action: PublicLinkCmd) -> Result<()> {
CtlResponse::PublicLink { link } => {
println!("{}", link.url.as_deref().unwrap_or("(no url returned)"));
}
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
},
PublicLinkCmd::Remove { path, id } => {
@@ -626,7 +640,7 @@ fn cmd_shared() -> Result<()> {
println!("{kind} {}{tags} [{}]", it.name, it.uid);
}
}
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
@@ -643,7 +657,7 @@ fn cmd_shared_with_me() -> Result<()> {
println!("{kind} {:>12} {} [{}]", e.size, e.name, e.uid);
}
}
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
@@ -665,7 +679,7 @@ fn cmd_activity(limit: usize) -> Result<()> {
println!("{mark} {when} {verb:<8} {}{detail}", a.target);
}
}
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
@@ -729,7 +743,7 @@ fn cmd_invitations(action: InvitationCmd) -> Result<()> {
println!("{} shared {kind} \"{name}\" [{}]", i.inviter_email, i.id);
}
}
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
},
InvitationCmd::Accept { id } => {
@@ -752,7 +766,7 @@ fn cmd_bookmarks(action: BookmarkCmd) -> Result<()> {
println!("{name} {} [{}]", b.url, b.token);
}
}
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
},
BookmarkCmd::Add { url, password } => {
@@ -773,7 +787,7 @@ fn cmd_bookmarks(action: BookmarkCmd) -> Result<()> {
fn ok_or_bail(resp: CtlResponse) -> Result<()> {
match resp {
CtlResponse::Ok { message } => println!("{message}"),
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
@@ -956,7 +970,7 @@ fn cmd_pin(path: PathBuf) -> Result<()> {
path: path_arg(&path)?,
})? {
CtlResponse::Ok { message } => println!("{message}"),
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
@@ -967,7 +981,7 @@ fn cmd_unpin(path: PathBuf) -> Result<()> {
path: path_arg(&path)?,
})? {
CtlResponse::Ok { message } => println!("{message}"),
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
@@ -1015,7 +1029,7 @@ fn cmd_transfers() -> Result<()> {
);
}
}
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
@@ -1029,7 +1043,7 @@ fn cmd_pins() -> Result<()> {
println!("{}", p.path);
}
}
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
@@ -1049,7 +1063,7 @@ fn cmd_ls(path: Option<PathBuf>) -> Result<()> {
println!("{kind}{pin} {:>12} {} [{}]", e.size, e.name, e.uid);
}
}
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
@@ -1088,7 +1102,7 @@ fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
println!("{} {} {thumb}", p.capture_time, p.uid);
}
}
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
@@ -1097,7 +1111,7 @@ fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
fn cmd_open_photo(uid: String) -> Result<()> {
match control_request(CtlRequest::OpenPhoto { uid })? {
CtlResponse::FilePath { path } => println!("{path}"),
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
@@ -1113,7 +1127,7 @@ fn cmd_search(query: String, limit: usize) -> Result<()> {
println!("{kind}{pin} {:>12} {}", h.size, h.path);
}
}
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
@@ -1125,7 +1139,7 @@ fn cmd_rename(path: PathBuf, new_name: String) -> Result<()> {
new_name,
})? {
CtlResponse::Ok { message } => println!("{message}"),
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
@@ -1137,7 +1151,7 @@ fn cmd_move(path: PathBuf, new_parent: PathBuf) -> Result<()> {
new_parent: path_arg(&new_parent)?,
})? {
CtlResponse::Ok { message } => println!("{message}"),
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
@@ -1148,7 +1162,7 @@ fn cmd_rm(path: PathBuf) -> Result<()> {
path: path_arg(&path)?,
})? {
CtlResponse::Ok { message } => println!("{message}"),
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
@@ -1163,7 +1177,7 @@ fn cmd_trash() -> Result<()> {
println!("{kind} {:>12} {} [{}]", e.size, e.name, e.uid);
}
}
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
@@ -1172,7 +1186,7 @@ fn cmd_trash() -> Result<()> {
fn cmd_restore(uids: Vec<String>) -> Result<()> {
match control_request(CtlRequest::Restore { uids })? {
CtlResponse::Ok { message } => println!("{message}"),
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
@@ -1185,7 +1199,7 @@ fn cmd_delete_forever(uids: Vec<String>) -> Result<()> {
))?;
match control_request(CtlRequest::DeleteForever { uids })? {
CtlResponse::Ok { message } => println!("{message}"),
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
@@ -1195,7 +1209,7 @@ fn cmd_empty_trash() -> Result<()> {
confirm("Permanently delete everything in the trash? This cannot be undone.")?;
match control_request(CtlRequest::EmptyTrash)? {
CtlResponse::Ok { message } => println!("{message}"),
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
@@ -1214,7 +1228,7 @@ fn cmd_refresh(target: Option<String>) -> Result<()> {
};
match control_request(CtlRequest::Refresh { scope })? {
CtlResponse::Ok { message } => println!("{message}"),
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
@@ -1240,7 +1254,7 @@ fn cmd_mkdir(parent: PathBuf, name: String) -> Result<()> {
name,
})? {
CtlResponse::Ok { message } => println!("{message}"),
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
@@ -1263,7 +1277,7 @@ fn cmd_upload(sources: Vec<PathBuf>, parent: PathBuf) -> Result<()> {
sources: abs,
})? {
CtlResponse::Ok { message } => println!("{message}"),
CtlResponse::Error { message } => bail!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
+136 -2
View File
@@ -874,8 +874,74 @@ pub enum Response {
/// The daemon's recent activity, newest first (reply to
/// [`Request::ListActivity`]).
Activity { items: Vec<ActivityEntry> },
/// The request failed.
Error { message: String },
/// The request failed. `message` is for the user; `kind` is for the code —
/// a front-end decides its copy and whether to offer a retry from `kind`,
/// never by matching on the text.
Error {
message: String,
#[serde(default)]
kind: ErrorKind,
},
}
impl Response {
/// Build a failure reply from a classified error, so the `kind` a
/// request-serving method decided survives the trip to the front-end.
pub fn error(e: crate::error::CoreError) -> Self {
Response::Error {
message: e.message,
kind: e.kind,
}
}
}
/// What class of thing went wrong, as opposed to what it read like.
///
/// The daemon answers most calls with prose assembled from whatever layer
/// failed (`"resolve path: ENOENT"`), which is fine to show and useless to
/// branch on. This is the branchable half: enough to pick the right copy, to
/// know whether retrying is meaningful, and to tell a caller's mistake apart
/// from an outage.
///
/// Deliberately coarse. A variant earns its place by changing what a front-end
/// *does*, not by naming a distinct cause — anything finer belongs in `message`.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorKind {
/// The API is unreachable and the request needed it. The cached tree is
/// still being served, so this is a "not right now", not a failure of the
/// thing the user asked for.
Offline,
/// No node at that path or uid. Usually means the front-end's listing is
/// stale, so the useful response is to reload it rather than to retry.
NotFound,
/// The account may not do that to that node — a viewer trying to write, a
/// share whose role was downgraded. Retrying changes nothing.
Denied,
/// The remote moved underneath the request: a name already taken, a
/// revision superseded. The caller has to decide, so never auto-retried.
Conflict,
/// The request itself was malformed — an empty name, a path with a `/` in
/// it, an unparseable uid. A bug in the caller, not a condition to retry.
Invalid,
/// The API was reached and refused, or the transfer broke. The one class
/// where an unchanged retry can legitimately succeed.
Remote,
/// Something on this machine failed: the database, the content cache, the
/// filesystem. Not the user's doing and not theirs to fix.
#[default]
Internal,
}
impl ErrorKind {
/// Whether repeating the identical request could plausibly succeed.
///
/// Drives whether a front-end offers "Try again" at all: offering it for a
/// [`NotFound`](Self::NotFound) or an [`Invalid`](Self::Invalid) teaches the
/// user that the button does nothing.
pub fn retryable(self) -> bool {
matches!(self, ErrorKind::Offline | ErrorKind::Remote)
}
}
/// Send one [`Request`] to the daemon listening on `socket` and read its
@@ -1101,4 +1167,72 @@ mod tests {
assert_eq!(line, serde_json::to_string(&back).unwrap());
}
}
/// A daemon built before `kind` existed sends `Error` without the field.
/// It must still parse, and land on the class that promises the least.
#[test]
fn an_error_without_a_kind_reads_as_internal() {
let wire = r#"{"Error":{"message":"something broke"}}"#;
let back: Response = serde_json::from_str(wire).unwrap();
match back {
Response::Error { message, kind } => {
assert_eq!(message, "something broke");
assert_eq!(kind, ErrorKind::Internal);
}
other => panic!("expected an error, got {other:?}"),
}
}
#[test]
fn error_kind_survives_the_wire() {
for kind in [
ErrorKind::Offline,
ErrorKind::NotFound,
ErrorKind::Denied,
ErrorKind::Conflict,
ErrorKind::Invalid,
ErrorKind::Remote,
ErrorKind::Internal,
] {
let line = serde_json::to_string(&Response::Error {
message: "x".into(),
kind,
})
.unwrap();
let back: Response = serde_json::from_str(&line).unwrap();
match back {
Response::Error { kind: got, .. } => assert_eq!(got, kind),
other => panic!("expected an error, got {other:?}"),
}
}
}
/// Retry is offered to the user off the back of this, so it has to mean
/// "an identical request could work", not "this looks recoverable".
#[test]
fn only_offline_and_remote_are_worth_retrying() {
assert!(ErrorKind::Offline.retryable());
assert!(ErrorKind::Remote.retryable());
for kind in [
ErrorKind::NotFound,
ErrorKind::Denied,
ErrorKind::Conflict,
ErrorKind::Invalid,
ErrorKind::Internal,
] {
assert!(!kind.retryable(), "{kind:?} must not offer a retry");
}
}
#[test]
fn response_error_carries_a_core_errors_classification() {
let r = Response::error(crate::error::CoreError::not_found("no such file"));
match r {
Response::Error { message, kind } => {
assert_eq!(message, "no such file");
assert_eq!(kind, ErrorKind::NotFound);
}
other => panic!("expected an error, got {other:?}"),
}
}
}
+109
View File
@@ -2,6 +2,8 @@
use thiserror::Error;
use crate::control::ErrorKind;
#[derive(Debug, Error)]
pub enum Error {
#[error("proton api: {0}")]
@@ -34,3 +36,110 @@ pub enum Error {
}
pub type Result<T> = std::result::Result<T, Error>;
/// A failure on its way to a front-end: prose for the user, plus the
/// [`ErrorKind`] the front-end actually branches on.
///
/// This is what the daemon's request-serving methods return, as opposed to
/// [`Error`] above, which is the plumbing error of the `pdfs-core` layer. The
/// two meet at the `From` impls below: a `rusqlite` or `io` failure carries no
/// information a user can act on, so it arrives here as
/// [`ErrorKind::Internal`] and keeps its text only for the log.
///
/// Classification happens where the failure is *understood* — at the API
/// boundary that knows a 404 from a 403, at the path resolver that knows an
/// `ENOENT` — never at the call site, which by then has only a string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoreError {
pub kind: ErrorKind,
pub message: String,
}
impl CoreError {
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
/// The API is unreachable. Carries fixed wording because there is only ever
/// one thing to say about it.
pub fn offline() -> Self {
Self::new(ErrorKind::Offline, "the Proton Drive API is unreachable")
}
pub fn not_found(what: impl Into<String>) -> Self {
Self::new(ErrorKind::NotFound, what)
}
pub fn denied(what: impl Into<String>) -> Self {
Self::new(ErrorKind::Denied, what)
}
pub fn conflict(what: impl Into<String>) -> Self {
Self::new(ErrorKind::Conflict, what)
}
pub fn invalid(what: impl Into<String>) -> Self {
Self::new(ErrorKind::Invalid, what)
}
pub fn remote(what: impl Into<String>) -> Self {
Self::new(ErrorKind::Remote, what)
}
pub fn internal(what: impl Into<String>) -> Self {
Self::new(ErrorKind::Internal, what)
}
/// Prefix the message while keeping the classification.
///
/// For adding the caller's context to an already-classified failure — the
/// kind was decided by whoever understood the error and must survive being
/// passed up.
pub fn context(mut self, what: &str) -> Self {
self.message = format!("{what}: {}", self.message);
self
}
}
impl std::fmt::Display for CoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for CoreError {}
impl From<rusqlite::Error> for CoreError {
fn from(e: rusqlite::Error) -> Self {
Self::internal(format!("database: {e}"))
}
}
impl From<std::io::Error> for CoreError {
fn from(e: std::io::Error) -> Self {
Self::internal(format!("io: {e}"))
}
}
impl From<serde_json::Error> for CoreError {
fn from(e: serde_json::Error) -> Self {
Self::internal(format!("serialization: {e}"))
}
}
impl From<Error> for CoreError {
fn from(e: Error) -> Self {
let kind = match &e {
Error::NotLoggedIn | Error::ReloginRequired => ErrorKind::Denied,
Error::Proton(_) => ErrorKind::Remote,
_ => ErrorKind::Internal,
};
Self::new(kind, e.to_string())
}
}
/// The result every request-serving method in the daemon returns.
pub type CoreResult<T> = std::result::Result<T, CoreError>;
+1 -1
View File
@@ -12,7 +12,7 @@ pub mod localindex;
pub mod service;
pub mod shell;
pub use error::{Error, Result};
pub use error::{CoreError, CoreResult, Error, Result};
// Re-export the SDK surface downstream crates need, so they depend on
// `pdfs-core` rather than pinning the SDK directly.
+69 -75
View File
@@ -15,6 +15,7 @@ use std::time::Instant;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use pdfs_core::{CoreError, CoreResult};
use pdfs_core::config::AppDirs;
use pdfs_core::control::{
ActivityKind, PhotoMonth, RefreshScope, Request as CtlRequest, Response as CtlResponse,
@@ -29,12 +30,12 @@ use super::{Core, count_noun, human_bytes, human_duration, parse_uid};
/// Turn a CLI-supplied path into a mountpoint-relative path. An absolute path
/// must live under `mountpoint`; a relative path is taken as already relative to
/// the mount root.
fn rel_to_mount(mountpoint: &Path, path: &str) -> Result<PathBuf, String> {
fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult<PathBuf> {
let p = Path::new(path);
if p.is_absolute() {
p.strip_prefix(mountpoint)
.map(Path::to_path_buf)
.map_err(|_| format!("{path} is not under the mountpoint"))
.map_err(|_| CoreError::invalid(format!("{path} is not under the mountpoint")))
} else {
Ok(p.to_path_buf())
}
@@ -75,18 +76,18 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Ok(name) => CtlResponse::Ok {
message: format!("pinned {name}"),
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(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}"),
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::ListPins) => CtlResponse::Pins {
pins: core.cache.list_pins(),
@@ -94,9 +95,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Ok(CtlRequest::ListDir { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.list_dir(&rel) {
Ok(entries) => CtlResponse::Entries { entries },
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::Refresh { scope }) => {
let result = match &scope {
@@ -116,7 +117,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Ok(()) => CtlResponse::Ok {
message: "refreshed".to_string(),
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::PhotosTimeline {
@@ -136,7 +137,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
items: Vec::new(),
counts: None,
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) {
@@ -150,9 +151,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
})
.collect(),
},
Err(e) => CtlResponse::Error {
message: e.to_string(),
},
Err(e) => CtlResponse::error(e.into()),
},
Ok(CtlRequest::PhotoThumbs { uids }) => {
let parsed: Vec<NodeUid> = uids.iter().filter_map(|u| parse_uid(u)).collect();
@@ -165,11 +164,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Ok(p) => CtlResponse::FilePath {
path: p.display().to_string(),
},
Err(e) => CtlResponse::Error { message: e },
},
None => CtlResponse::Error {
message: format!("bad uid: {uid}"),
Err(e) => CtlResponse::error(e),
},
None => CtlResponse::error(CoreError::invalid(format!("bad uid: {uid}"))),
},
Ok(CtlRequest::UploadPhoto {
name,
@@ -210,9 +207,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
message: format!("uploaded photo with uid {uid}"),
}
}
Err(e) => CtlResponse::Error {
message: format!("upload photo failed: {e}"),
},
Err(e) => CtlResponse::error(CoreError::remote(format!("upload photo failed: {e}"))),
}
}
Ok(CtlRequest::OpenFile { path }) => match rel_to_mount(mountpoint, &path) {
@@ -220,20 +215,20 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Ok(p) => CtlResponse::FilePath {
path: p.display().to_string(),
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::Search { query, limit }) => match core.search(&query, limit) {
Ok(hits) => CtlResponse::SearchResults { hits },
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::SearchLocal { query, limit }) => match core.search_local(&query, limit) {
Ok(hits) => CtlResponse::LocalResults {
hits,
indexing: core.indexing.load(Ordering::Relaxed),
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::Rename { path, new_name }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.rename(&rel, &new_name) {
@@ -245,10 +240,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
}
Err(e) => {
core.log_activity(ActivityKind::Rename, &path, &e, false);
CtlResponse::Error { message: e }
CtlResponse::error(e)
}
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::Move { path, new_parent }) => {
match (
@@ -269,10 +264,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
}
Err(e) => {
core.log_activity(ActivityKind::Move, &path, &e, false);
CtlResponse::Error { message: e }
CtlResponse::error(e)
}
},
(Err(e), _) | (_, Err(e)) => CtlResponse::Error { message: e },
(Err(e), _) | (_, Err(e)) => CtlResponse::error(e),
}
}
Ok(CtlRequest::Delete { path }) => match rel_to_mount(mountpoint, &path) {
@@ -285,10 +280,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
}
Err(e) => {
core.log_activity(ActivityKind::Trash, &path, &e, false);
CtlResponse::Error { message: e }
CtlResponse::error(e)
}
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::CreateFolder { parent, name }) => match rel_to_mount(mountpoint, &parent) {
Ok(parent_rel) => match core.create_folder(&parent_rel, &name) {
@@ -300,10 +295,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
}
Err(e) => {
core.log_activity(ActivityKind::CreateFolder, &name, &e, false);
CtlResponse::Error { message: e }
CtlResponse::error(e)
}
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::UploadFile {
parent,
@@ -319,10 +314,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
}
Err(e) => {
core.log_activity(ActivityKind::Upload, &name, &e, false);
CtlResponse::Error { message: e }
CtlResponse::error(e)
}
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::UploadPaths { parent, sources }) => {
match rel_to_mount(mountpoint, &parent) {
@@ -375,12 +370,12 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
message: format!("uploading {n} item(s)"),
}
}
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::ListTrash) => match core.list_trash() {
Ok(entries) => CtlResponse::Entries { entries },
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::Restore { uids }) => match core.restore(&uids) {
Ok(n) => {
@@ -389,7 +384,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
message: format!("restored {n} item(s)"),
}
}
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::DeleteForever { uids }) => match core.delete_forever(&uids) {
Ok(n) => {
@@ -403,7 +398,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
message: format!("permanently deleted {n} item(s)"),
}
}
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::EmptyTrash) => match core.empty_trash() {
Ok(n) => {
@@ -412,7 +407,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
message: format!("emptied trash ({n} item(s))"),
}
}
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::PurgeCache) => {
let freed = core.cache.clear_unpinned();
@@ -439,29 +434,29 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Ok(Ok(())) => CtlResponse::Ok {
message: format!("cache budget set to {bytes} bytes"),
},
Ok(Err(e)) => CtlResponse::Error {
message: format!("budget applied but config write failed: {e}"),
},
Err(e) => CtlResponse::Error {
message: format!("budget applied but config unavailable: {e}"),
},
Ok(Err(e)) => CtlResponse::error(CoreError::internal(format!(
"budget applied but config write failed: {e}"
))),
Err(e) => CtlResponse::error(CoreError::internal(format!(
"budget applied but config unavailable: {e}"
))),
}
}
Ok(CtlRequest::ListDevices) => match core.list_devices() {
Ok(items) => CtlResponse::Devices { items },
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::RenameDevice { uid, name }) => match core.rename_device(&uid, &name) {
Ok(()) => CtlResponse::Ok {
message: format!("renamed device to {name}"),
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::DeleteDevice { uid }) => match core.delete_device(&uid) {
Ok(()) => CtlResponse::Ok {
message: "device deleted".to_string(),
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::AddSyncFolder { local_path }) => {
// Registering the device and uploading a folder tree far outlasts the
@@ -498,14 +493,14 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
}
Ok(CtlRequest::ListSyncFolders) => match core.list_sync_folders() {
Ok(items) => CtlResponse::SyncFolders { items },
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::RemoveSyncFolder { id, delete_remote }) => {
match core.remove_sync_folder(id, delete_remote) {
Ok(()) => CtlResponse::Ok {
message: "removed synced folder".to_string(),
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::SetSyncFolderMode { id, mode }) => {
@@ -514,7 +509,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
core.log_activity(ActivityKind::Upload, &message, "", true);
CtlResponse::Ok { message }
}
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::SyncNow { id }) => {
@@ -546,17 +541,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
}
Err(e) => {
core.log_activity(ActivityKind::Share, &path, &e, false);
CtlResponse::Error { message: e }
CtlResponse::error(e)
}
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::ListShare { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.list_share(&rel) {
Ok((entries, link)) => CtlResponse::Share { entries, link },
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::UpdateShareRole {
path,
@@ -568,9 +563,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Ok(()) => CtlResponse::Ok {
message: format!("role updated to {role}"),
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::RemoveShareEntry { path, id, kind }) => {
match rel_to_mount(mountpoint, &path) {
@@ -581,9 +576,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
message: "removed".to_string(),
}
}
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::CreatePublicLink {
@@ -599,10 +594,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
}
Err(e) => {
core.log_activity(ActivityKind::PublicLink, &path, &e, false);
CtlResponse::Error { message: e }
CtlResponse::error(e)
}
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::RemovePublicLink { path, id }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.remove_public_link(&rel, &id) {
@@ -612,13 +607,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
message: "public link removed".to_string(),
}
}
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::ListSharedWithMe) => match core.list_shared_with_me() {
Ok(entries) => CtlResponse::Entries { entries },
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::LeaveShared { uid }) => match core.leave_shared(&uid) {
Ok(()) => {
@@ -627,52 +622,51 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
message: "left shared item".to_string(),
}
}
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::ListInvitations) => match core.list_invitations() {
Ok(items) => CtlResponse::Invitations { items },
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::AcceptInvitation { id }) => match core.accept_invitation(&id) {
Ok(()) => CtlResponse::Ok {
message: "invitation accepted".to_string(),
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::RejectInvitation { id }) => match core.reject_invitation(&id) {
Ok(()) => CtlResponse::Ok {
message: "invitation rejected".to_string(),
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::ListBookmarks) => match core.list_bookmarks() {
Ok(items) => CtlResponse::Bookmarks { items },
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::CreateBookmark { url, password }) => {
match core.create_bookmark(&url, password.as_deref()) {
Ok(()) => CtlResponse::Ok {
message: "bookmark saved".to_string(),
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::DeleteBookmark { token }) => match core.delete_bookmark(&token) {
Ok(()) => CtlResponse::Ok {
message: "bookmark removed".to_string(),
},
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::ListSharedByMe) => match core.list_shared_by_me() {
Ok(items) => CtlResponse::SharedByMe { items },
Err(e) => CtlResponse::Error { message: e },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::ListActivity { limit }) => CtlResponse::Activity {
items: core.list_activity(limit),
},
Err(e) => CtlResponse::Error {
message: format!("bad request: {e}"),
},
// The request did not parse: the caller sent something malformed.
Err(e) => CtlResponse::error(CoreError::invalid(format!("bad request: {e}"))),
};
let mut out = match serde_json::to_vec(&response) {
Ok(v) => v,
+45 -44
View File
@@ -12,6 +12,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use parking_lot::Mutex;
use pdfs_core::{CoreError, CoreResult};
use pdfs_core::control::{ActivityKind, DeviceInfo, JobItem, SyncFolderInfo, SyncPhase, SyncProgress};
use pdfs_core::db::{StoredDevice, StoredSyncFolder};
use fuser::{Config, MountOption, Session};
@@ -31,11 +32,11 @@ impl Core {
/// List the account's registered devices, flagging the one *this* machine
/// syncs to so a front-end can treat it as more than another computer in the
/// list — deleting it takes this machine's synced folders down with it.
pub(crate) fn list_devices(&self) -> Result<Vec<DeviceInfo>, String> {
pub(crate) fn list_devices(&self) -> CoreResult<Vec<DeviceInfo>> {
let devices = self
.rt
.block_on(self.client.enumerate_devices())
.map_err(|e| format!("list devices: {e}"))?;
.map_err(|e| CoreError::remote(format!("list devices: {e}")))?;
// No cached device row yet means this machine syncs nothing, so none of
// the listed devices is ours.
let this_uid = self.db.device_get().ok().flatten().map(|d| d.uid);
@@ -55,23 +56,23 @@ impl Core {
}
/// Rename a device by its uid.
pub(crate) fn rename_device(&self, uid: &str, name: &str) -> Result<(), String> {
pub(crate) fn rename_device(&self, uid: &str, name: &str) -> CoreResult<()> {
if name.is_empty() {
return Err("device name must not be empty".to_string());
return Err(CoreError::invalid("device name must not be empty"));
}
let device_uid = DeviceUid::from(uid);
self.rt
.block_on(self.client.rename_device(&device_uid, name))
.map_err(|e| format!("rename device: {e}"))?;
.map_err(|e| CoreError::remote(format!("rename device: {e}")))?;
Ok(())
}
/// Delete (deregister) a device by its uid.
pub(crate) fn delete_device(&self, uid: &str) -> Result<(), String> {
pub(crate) fn delete_device(&self, uid: &str) -> CoreResult<()> {
let device_uid = DeviceUid::from(uid);
self.rt
.block_on(self.client.delete_device(&device_uid))
.map_err(|e| format!("delete device: {e}"))?;
.map_err(|e| CoreError::remote(format!("delete device: {e}")))?;
Ok(())
}
@@ -81,20 +82,20 @@ impl Core {
/// it so restarts reuse the same device. Recovery matches an existing remote
/// Linux device by name before creating a new one, so a lost local record
/// doesn't orphan the device's root folder.
pub(crate) fn ensure_device(&self) -> Result<StoredDevice, String> {
pub(crate) fn ensure_device(&self) -> CoreResult<StoredDevice> {
let name = this_hostname();
// Enumerate the remote devices once: used both to validate any cached
// record and to recover an existing device by name.
let remote = self
.rt
.block_on(self.client.enumerate_devices())
.map_err(|e| format!("enumerate devices: {e}"))?;
.map_err(|e| CoreError::remote(format!("enumerate devices: {e}")))?;
// A cached device is only trustworthy if it still exists remotely. A
// device deleted from another client (or the web UI) leaves a stale row
// whose root folder is gone, so creating folders under it fails with
// "parent node is not a folder". Re-register in that case.
if let Some(dev) = self.db.device_get().map_err(|e| format!("db: {e:?}"))? {
if let Some(dev) = self.db.device_get().map_err(|e| CoreError::internal(format!("db: {e:?}")))? {
if remote.iter().any(|d| d.uid.to_string() == dev.uid) {
return Ok(dev);
}
@@ -117,7 +118,7 @@ impl Core {
let d = self
.rt
.block_on(self.client.create_device(&name, DeviceType::Linux))
.map_err(|e| format!("create device: {e}"))?;
.map_err(|e| CoreError::remote(format!("create device: {e}")))?;
StoredDevice {
uid: d.uid.to_string(),
share_id: d.share_id.to_string(),
@@ -127,7 +128,7 @@ impl Core {
}
}
};
self.db.device_set(&dev).map_err(|e| format!("db: {e:?}"))?;
self.db.device_set(&dev).map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
Ok(dev)
}
@@ -137,18 +138,18 @@ impl Core {
&self,
root_uid: &NodeUid,
name: &str,
) -> Result<Option<NodeUid>, String> {
) -> CoreResult<Option<NodeUid>> {
let uids = self
.rt
.block_on(self.client.enumerate_folder_children_node_uids(root_uid))
.map_err(|e| format!("list device root: {e}"))?;
.map_err(|e| CoreError::remote(format!("list device root: {e}")))?;
if uids.is_empty() {
return Ok(None);
}
let nodes = self
.rt
.block_on(self.client.enumerate_nodes(&uids))
.map_err(|e| format!("resolve device root children: {e}"))?;
.map_err(|e| CoreError::remote(format!("resolve device root children: {e}")))?;
Ok(nodes
.into_iter()
.find(|n| n.is_folder() && !n.trashed && n.name == name)
@@ -159,37 +160,37 @@ impl Core {
/// needed, create a matching folder under the device root, upload the local
/// tree into it once, and record the mapping. Phase 1 is a one-shot upload —
/// the two-way engine (Phase 2) reconciles later changes.
pub(crate) fn add_sync_folder(&self, local: &Path) -> Result<StoredSyncFolder, String> {
pub(crate) fn add_sync_folder(&self, local: &Path) -> CoreResult<StoredSyncFolder> {
let meta =
std::fs::metadata(local).map_err(|e| format!("stat {}: {e}", local.display()))?;
std::fs::metadata(local).map_err(|e| CoreError::internal(format!("stat {}: {e}", local.display())))?;
if !meta.is_dir() {
return Err(format!("{} is not a directory", local.display()));
return Err(CoreError::invalid(format!("{} is not a directory", local.display())));
}
let local = local
.canonicalize()
.map_err(|e| format!("canonicalize {}: {e}", local.display()))?;
.map_err(|e| CoreError::internal(format!("canonicalize {}: {e}", local.display())))?;
let local_str = local.to_string_lossy().to_string();
// Reject duplicates up front for a clear error (UNIQUE would also catch it).
if self
.db
.sync_folder_list()
.map_err(|e| format!("db: {e:?}"))?
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.iter()
.any(|f| f.local_path == local_str)
{
return Err(format!("{} is already synced", local.display()));
return Err(CoreError::invalid(format!("{} is already synced", local.display())));
}
let name = local
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| format!("unusable folder name: {}", local.display()))?
.ok_or_else(|| CoreError::invalid(format!("unusable folder name: {}", local.display())))?
.to_string();
let device = self.ensure_device()?;
let root_uid = parse_uid(&device.root_uid)
.ok_or_else(|| format!("bad device root uid: {}", device.root_uid))?;
.ok_or_else(|| CoreError::internal(format!("bad device root uid: {}", device.root_uid)))?;
// The synced folder's remote root: the folder under the device root named
// after the local basename. Reuse an existing one rather than creating a
@@ -209,13 +210,13 @@ impl Core {
self.client
.create_folder(&root_uid, &name, Some(now_secs())),
)
.map_err(|e| format!("create device folder {name}: {e}"))?,
.map_err(|e| CoreError::remote(format!("create device folder {name}: {e}")))?,
};
let id = self
.db
.sync_folder_add(&local_str, &remote_root.to_string(), &device.share_id)
.map_err(|e| format!("db: {e:?}"))?;
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
// Hand the initial upload to the sync engine: an empty baseline against a
// full local tree reconciles as "upload everything", and the folder is
@@ -226,18 +227,18 @@ impl Core {
info!(local = %local.display(), id, "added sync folder");
self.db
.sync_folder_get(id)
.map_err(|e| format!("db: {e:?}"))?
.ok_or_else(|| "sync folder vanished after insert".to_string())
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.ok_or_else(|| CoreError::not_found("sync folder vanished after insert"))
}
/// List this device's synced folders for the front-ends, each carrying the
/// live progress of its pass when one is running.
pub(crate) fn list_sync_folders(&self) -> Result<Vec<SyncFolderInfo>, String> {
pub(crate) fn list_sync_folders(&self) -> CoreResult<Vec<SyncFolderInfo>> {
let progress = self.sync_progress.lock();
Ok(self
.db
.sync_folder_list()
.map_err(|e| format!("db: {e:?}"))?
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.into_iter()
.map(|f| {
let live = progress.get(&f.id).cloned();
@@ -320,12 +321,12 @@ impl Core {
/// Remove a synced folder from the sync set. `delete_remote` also deletes its
/// folder under the device root; otherwise the cloud copy is left in place.
pub(crate) fn remove_sync_folder(&self, id: i64, delete_remote: bool) -> Result<(), String> {
pub(crate) fn remove_sync_folder(&self, id: i64, delete_remote: bool) -> CoreResult<()> {
let folder = self
.db
.sync_folder_get(id)
.map_err(|e| format!("db: {e:?}"))?
.ok_or_else(|| format!("no synced folder with id {id}"))?;
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
// An `ondemand` folder *is* a FUSE mount over its local path, so dropping
// only the row would strand the mount: the path would keep serving a
// folder the daemon no longer tracks, and nothing would ever unmount it.
@@ -347,9 +348,9 @@ impl Core {
if !self
.db
.sync_folder_remove(id)
.map_err(|e| format!("db: {e:?}"))?
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
{
return Err(format!("no synced folder with id {id}"));
return Err(CoreError::invalid(format!("no synced folder with id {id}")));
}
// Stop watching the folder we just dropped.
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
@@ -394,22 +395,22 @@ impl Core {
/// intent is recorded and the engine applies it at the end of the pass already
/// running — which, seeing a queued `ondemand`, also stops pulling down files
/// it is about to evict ([`Core::push_pass`]).
pub(crate) fn request_sync_folder_mode(&self, id: i64, mode: &str) -> Result<String, String> {
pub(crate) fn request_sync_folder_mode(&self, id: i64, mode: &str) -> CoreResult<String> {
if mode != "mirror" && mode != "ondemand" {
return Err(format!("unknown mode {mode:?} (want mirror|ondemand)"));
return Err(CoreError::invalid(format!("unknown mode {mode:?} (want mirror|ondemand)")));
}
let folder = self
.db
.sync_folder_get(id)
.map_err(|e| format!("db: {e:?}"))?
.ok_or_else(|| format!("no synced folder with id {id}"))?;
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
if folder.mode == mode {
// Already there. A queued switch the other way is the user changing
// their mind back before it landed, so withdraw it.
if folder.pending_mode.is_some() {
self.db
.sync_folder_set_pending_mode(id, None)
.map_err(|e| format!("db: {e:?}"))?;
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
return Ok(format!("staying {mode}"));
}
@@ -423,7 +424,7 @@ impl Core {
Err(SwitchBlocked::NotNow) => {
self.db
.sync_folder_set_pending_mode(id, Some(mode))
.map_err(|e| format!("db: {e:?}"))?;
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
Ok(match mode {
"ondemand" => format!(
@@ -436,7 +437,7 @@ impl Core {
),
})
}
Err(SwitchBlocked::Failed(e)) => Err(e),
Err(SwitchBlocked::Failed(e)) => Err(CoreError::internal(e)),
}
}
@@ -542,7 +543,7 @@ impl Core {
let session = self
.spawn_ondemand_mount(&local, root)
.map_err(SwitchBlocked::Failed)?;
.map_err(|e| SwitchBlocked::Failed(e.to_string()))?;
self.mounts.lock().insert(id, session);
// Persist the mode only now that the mount is actually up. Writing it
// first would strand the folder on any failure below: the engine skips
@@ -587,7 +588,7 @@ impl Core {
/// Spawn a secondary FUSE session rooted at `root` over `local` on a forked
/// inode space. Clears any stale kernel mount first (a crashed run can leave
/// one, which would fail the fresh mount with EBUSY).
pub(crate) fn spawn_ondemand_mount(&self, local: &Path, root: Node) -> Result<BackgroundSession, String> {
pub(crate) fn spawn_ondemand_mount(&self, local: &Path, root: Node) -> CoreResult<BackgroundSession> {
clear_stale_mount(local);
let mut config = Config::default();
config.mount_options = vec![
@@ -598,7 +599,7 @@ impl Core {
let fs = ProtonFs::new(self.fork_state(), root);
Session::new(fs, local, &config)
.and_then(|s| s.spawn())
.map_err(|e| format!("mount {}: {e}", local.display()))
.map_err(|e| CoreError::internal(format!("mount {}: {e}", local.display())))
}
/// Re-establish FUSE mounts for folders left in `ondemand` mode across a daemon
+3 -2
View File
@@ -24,6 +24,7 @@ use std::fs::File;
use std::path::{Path, PathBuf};
use std::sync::atomic::Ordering;
use pdfs_core::CoreError;
use pdfs_core::cache::StagedWrite;
use pdfs_core::control::{ActivityKind, TransferDirection};
use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp};
@@ -394,7 +395,7 @@ impl Core {
local: &NodeUid,
real: &NodeUid,
) -> Result<(), Box<dyn std::error::Error>> {
let node = self.fetch_node(real).map_err(|e| format!("{e:?}"))?;
let node = self.fetch_node(real).map_err(|e| CoreError::remote(format!("{e:?}")))?;
// Repoints queued children and node rows, and drops the placeholder row.
self.db
.remap_local_uid(&local.to_string(), &real.to_string())?;
@@ -675,7 +676,7 @@ impl Core {
meta.base_size,
&written,
)
.map_err(|e| format!("gap-fill from base failed: {e:?}"))?;
.map_err(|e| CoreError::remote(format!("gap-fill from base failed: {e:?}")))?;
}
let name = {
+115 -107
View File
@@ -49,6 +49,7 @@ use fuser::{
RenameFlags, ReplyAttr, ReplyCreate, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry,
ReplyOpen, ReplyWrite, Request, Session, TimeOrNow, WriteFlags,
};
use pdfs_core::{CoreError, CoreResult};
use pdfs_core::cache::{BLOCK_SIZE, Baseline, ContentCache, StagedWrite};
use pdfs_core::config::AppDirs;
use pdfs_core::control::{
@@ -819,6 +820,33 @@ impl Core {
Ok((ino, uid))
}
/// [`resolve_path`](Self::resolve_path) for the request-serving side.
///
/// The kernel-facing resolver answers in `Errno` because that is what the
/// FUSE reply needs. A front-end needs the opposite: `{:?}` of a libc
/// constant is not something to show a user, and "does not exist" and "the
/// API is down" want different copy and different buttons. This is the one
/// place that knows enough to tell them apart, so it is the place that does.
fn resolve(&self, rel: &Path) -> CoreResult<(u64, NodeUid)> {
self.resolve_path(rel).map_err(|e| {
let what = rel.display();
// `Errno` is neither `PartialEq` nor structural-match, so this
// compares the raw codes rather than the constants.
match e.code() {
libc::ENOENT => CoreError::not_found(format!("no such file or folder: {what}")),
libc::EACCES | libc::EPERM => {
CoreError::denied(format!("not allowed to read {what}"))
}
// The resolver walks the tree lazily, so a cold path needs the
// API. Offline that surfaces as EIO, which on its own would read
// to the user as a broken file rather than a missing network.
libc::EIO if !self.online.load(Ordering::Relaxed) => CoreError::offline(),
libc::EINVAL => CoreError::invalid(format!("not a usable path: {what}")),
_ => CoreError::remote(format!("could not resolve {what}: {e:?}")),
}
})
}
/// Fetch a single node's current metadata from the remote.
fn fetch_node(&self, uid: &NodeUid) -> Result<Node, Errno> {
match self.fetch_node_remote(uid) {
@@ -1811,13 +1839,11 @@ impl Core {
/// Pin the node at mountpoint-relative `rel`. A file downloads its full
/// plaintext into the content cache; a folder records a recursive pin and
/// downloads every descendant file (selective sync). Returns a human message.
fn pin(&self, rel: &Path) -> Result<String, String> {
let (ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
fn pin(&self, rel: &Path) -> CoreResult<String> {
let (ino, uid) = self.resolve(rel)?;
let (name, is_folder, mtime, size) = {
let st = self.state.lock();
let e = st.entries.get(&ino).ok_or("node vanished")?;
let e = st.entries.get(&ino).ok_or_else(|| CoreError::not_found("node vanished"))?;
(
e.node.name.clone(),
e.node.is_folder(),
@@ -1830,19 +1856,19 @@ impl Core {
// exempt before we start filling the cache with the subtree.
self.cache
.add_pin(&uid, rel, true)
.map_err(|e| format!("pin: {e}"))?;
.map_err(|e| CoreError::remote(format!("pin: {e}")))?;
let n = self.pin_subtree(ino)?;
return Ok(format!("{name} ({n} files)"));
}
let bytes = self
.download_file_tracked(&uid, &name, size)
.map_err(|e| format!("download: {e}"))?;
.map_err(|e| CoreError::remote(format!("download: {e}")))?;
self.cache
.store(&uid, mtime, size, &bytes)
.map_err(|e| format!("cache store: {e}"))?;
.map_err(|e| CoreError::internal(format!("cache store: {e}")))?;
self.cache
.add_pin(&uid, rel, false)
.map_err(|e| format!("pin: {e}"))?;
.map_err(|e| CoreError::remote(format!("pin: {e}")))?;
Ok(name)
}
@@ -1850,12 +1876,12 @@ impl Core {
/// returning the count cached (already-fresh blobs counted, not re-fetched).
/// Walks the tree depth-first, enumerating each folder so a cold subtree is
/// fully discovered; the lock is dropped before each network download.
fn pin_subtree(&self, ino: u64) -> Result<usize, String> {
fn pin_subtree(&self, ino: u64) -> CoreResult<usize> {
let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new();
let mut stack = vec![ino];
while let Some(dir) = stack.pop() {
self.ensure_children(dir)
.map_err(|e| format!("enumerate: {e:?}"))?;
.map_err(|e| CoreError::remote(format!("enumerate: {e:?}")))?;
let st = self.state.lock();
if let Some(kids) = st.children.get(&dir) {
for &k in kids {
@@ -1924,10 +1950,8 @@ impl Core {
/// Unpin the node at `rel`, evicting its cached content. For a folder, also
/// evicts every descendant's cached blob (the subtree is no longer kept).
fn unpin(&self, rel: &Path) -> Result<String, String> {
let (ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
fn unpin(&self, rel: &Path) -> CoreResult<String> {
let (ino, uid) = self.resolve(rel)?;
let (name, is_folder) = {
let st = self.state.lock();
st.entries
@@ -1937,7 +1961,7 @@ impl Core {
};
self.cache
.remove_pin(&uid)
.map_err(|e| format!("unpin: {e}"))?;
.map_err(|e| CoreError::remote(format!("unpin: {e}")))?;
// A recursively-pinned folder's descendants were eviction-exempt; now
// that the pin is gone, reclaim their blobs eagerly instead of waiting
// for budget pressure. Descendants come from the DB node tree.
@@ -1963,12 +1987,10 @@ impl Core {
/// List the directory at mountpoint-relative `rel` for the in-app browser:
/// 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:?}"))?;
fn list_dir(&self, rel: &Path) -> CoreResult<Vec<DirEntry>> {
let (ino, _uid) = self.resolve(rel)?;
self.ensure_children(ino)
.map_err(|e| format!("enumerate: {e:?}"))?;
.map_err(|e| CoreError::remote(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)> = {
@@ -2011,11 +2033,11 @@ impl Core {
/// Full-text search node names against the local SQLite index, mapping each
/// DB hit to the wire [`SearchHit`] (resolving live pin state from the cache,
/// which the DB doesn't track). Pure local lookup — never hits the network.
fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>, String> {
fn search(&self, query: &str, limit: usize) -> CoreResult<Vec<SearchHit>> {
let hits = self
.db
.search(query, limit)
.map_err(|e| format!("search: {e}"))?;
.map_err(|e| CoreError::remote(format!("search: {e}")))?;
Ok(hits
.into_iter()
.map(|h| SearchHit {
@@ -2033,11 +2055,11 @@ impl Core {
/// Search the index of files on this machine (outside Drive), built by the
/// background scanner in [`run_local_index`]. Pure local lookup, never hits
/// the network — and never touches the FUSE mount, which the scanner excludes.
fn search_local(&self, query: &str, limit: usize) -> Result<Vec<LocalHit>, String> {
fn search_local(&self, query: &str, limit: usize) -> CoreResult<Vec<LocalHit>> {
let hits = self
.db
.search_local(query, limit)
.map_err(|e| format!("local search: {e}"))?;
.map_err(|e| CoreError::remote(format!("local search: {e}")))?;
Ok(hits
.into_iter()
.map(|h| LocalHit {
@@ -2075,15 +2097,13 @@ impl Core {
/// into the content cache, returning its on-disk path (served from cache
/// 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:?}"))?;
fn open_file(&self, rel: &Path) -> CoreResult<PathBuf> {
let (ino, uid) = self.resolve(rel)?;
let (name, mtime, size) = {
let st = self.state.lock();
let e = st.entries.get(&ino).ok_or("node vanished")?;
let e = st.entries.get(&ino).ok_or_else(|| CoreError::not_found("node vanished"))?;
if !e.node.is_file() {
return Err("not a regular file".into());
return Err(CoreError::invalid("not a regular file"));
}
(
e.node.name.clone(),
@@ -2096,10 +2116,10 @@ impl Core {
}
let bytes = self
.download_file_tracked(&uid, &name, size)
.map_err(|e| format!("download: {e}"))?;
.map_err(|e| CoreError::remote(format!("download: {e}")))?;
self.cache
.store(&uid, mtime, size, &bytes)
.map_err(|e| format!("cache store: {e}"))?;
.map_err(|e| CoreError::internal(format!("cache store: {e}")))?;
Ok(self.cache.content_path(&uid))
}
@@ -2118,16 +2138,14 @@ impl Core {
/// Mirrors the FUSE `rename` write path: rename on the remote, forget the
/// node so it re-interns under its new name, and drop the parent listing so
/// the next `ListDir` re-enumerates.
fn rename(&self, rel: &Path, new_name: &str) -> Result<String, String> {
fn rename(&self, rel: &Path, new_name: &str) -> CoreResult<String> {
if new_name.is_empty() || new_name.contains('/') {
return Err(format!("invalid name: {new_name:?}"));
return Err(CoreError::invalid(format!("invalid name: {new_name:?}")));
}
let (_ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
let (_ino, uid) = self.resolve(rel)?;
self.rt
.block_on(self.client.rename_node(&uid, new_name, None))
.map_err(|e| format!("rename: {e}"))?;
.map_err(|e| CoreError::remote(format!("rename: {e}")))?;
self.state.lock().forget(&uid);
self.invalidate_parent_listing(rel);
Ok(new_name.to_string())
@@ -2136,16 +2154,14 @@ impl Core {
/// Move a file or folder into the folder at `new_parent_rel`. Both paths are
/// mountpoint-relative. Forgets the node and invalidates both the source and
/// destination listings so each re-enumerates on next access.
fn move_to(&self, rel: &Path, new_parent_rel: &Path) -> Result<String, String> {
let (_ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
fn move_to(&self, rel: &Path, new_parent_rel: &Path) -> CoreResult<String> {
let (_ino, uid) = self.resolve(rel)?;
let (pino, new_parent_uid) = self
.resolve_path(new_parent_rel)
.map_err(|e| format!("resolve new parent: {e:?}"))?;
.map_err(|e| CoreError::remote(format!("resolve new parent: {e:?}")))?;
self.rt
.block_on(self.client.move_node(&uid, &new_parent_uid))
.map_err(|e| format!("move: {e}"))?;
.map_err(|e| CoreError::remote(format!("move: {e}")))?;
let name = self
.state
.lock()
@@ -2159,13 +2175,11 @@ impl Core {
/// Trash a file or folder. `rel` is mountpoint-relative. Forgets the node,
/// evicts any cached content, and invalidates the parent listing.
fn delete(&self, rel: &Path) -> Result<String, String> {
let (_ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
fn delete(&self, rel: &Path) -> CoreResult<String> {
let (_ino, uid) = self.resolve(rel)?;
self.rt
.block_on(self.client.trash_nodes(std::slice::from_ref(&uid)))
.map_err(|e| format!("trash: {e}"))?;
.map_err(|e| CoreError::remote(format!("trash: {e}")))?;
let name = self
.state
.lock()
@@ -2189,7 +2203,7 @@ impl Core {
/// back at DB speed and is refreshed in the background past [`TRASH_TTL`].
/// Our own trash mutations invalidate it outright (see
/// [`Core::invalidate_trash`]), so the TTL only covers changes made elsewhere.
fn list_trash(&self) -> Result<Vec<DirEntry>, String> {
fn list_trash(&self) -> CoreResult<Vec<DirEntry>> {
let stale = self.listing_stale(TRASH_SYNCED_MS, TRASH_TTL);
// Never fetched: this request has to wait for it.
if self.db.state_i64(TRASH_SYNCED_MS).ok().flatten().is_none() {
@@ -2201,7 +2215,7 @@ impl Core {
Ok(self
.db
.trash_list()
.map_err(|e| e.to_string())?
.map_err(CoreError::from)?
.into_iter()
.map(|item| DirEntry {
name: item.name,
@@ -2219,19 +2233,19 @@ impl Core {
}
/// Re-fetch the trash listing from the server and persist it.
async fn refresh_trash(&self) -> Result<(), String> {
async fn refresh_trash(&self) -> CoreResult<()> {
let uids = self
.client
.enumerate_trash_node_uids()
.await
.map_err(|e| format!("enumerate trash: {e}"))?;
.map_err(|e| CoreError::remote(format!("enumerate trash: {e}")))?;
let nodes = if uids.is_empty() {
Vec::new()
} else {
self.client
.enumerate_nodes(&uids)
.await
.map_err(|e| format!("enumerate nodes: {e}"))?
.map_err(|e| CoreError::remote(format!("enumerate nodes: {e}")))?
};
let items: Vec<StoredTrash> = nodes
.into_iter()
@@ -2243,7 +2257,7 @@ impl Core {
mtime: node.modification_time,
})
.collect();
self.db.trash_replace(&items).map_err(|e| e.to_string())?;
self.db.trash_replace(&items).map_err(CoreError::from)?;
let _ = self.db.set_state_i64(TRASH_SYNCED_MS, now_ms());
Ok(())
}
@@ -2279,10 +2293,8 @@ impl Core {
/// Drop one folder's cached child listing (`rel` is mountpoint-relative), so
/// the next `ListDir`/`readdir` re-enumerates it from the server. Backs
/// [`CtlRequest::Refresh`] with a [`RefreshScope::Dir`] scope.
fn refresh_dir(&self, rel: &Path) -> Result<(), String> {
let (ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
fn refresh_dir(&self, rel: &Path) -> CoreResult<()> {
let (ino, uid) = self.resolve(rel)?;
// Clear the DB flag directly rather than through
// `State::invalidate_listing`, which no-ops when the folder has no
// in-memory listing to drop — exactly the case for a folder hydrated
@@ -2290,19 +2302,19 @@ impl Core {
// listing worth refreshing.
self.db
.set_listed(&uid, false)
.map_err(|e| format!("db: {e:?}"))?;
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
self.state.lock().children.remove(&ino);
Ok(())
}
/// Parse wire uids (`volume~link`) into [`NodeUid`]s, rejecting the whole
/// batch if any is malformed — a partial trash mutation is worse than none.
fn parse_uids(uids: &[String]) -> Result<Vec<NodeUid>, String> {
fn parse_uids(uids: &[String]) -> CoreResult<Vec<NodeUid>> {
if uids.is_empty() {
return Err("no nodes given".to_string());
return Err(CoreError::invalid("no nodes given"));
}
uids.iter()
.map(|u| parse_uid(u).ok_or_else(|| format!("invalid uid: {u}")))
.map(|u| parse_uid(u).ok_or_else(|| CoreError::invalid(format!("invalid uid: {u}"))))
.collect()
}
@@ -2310,18 +2322,18 @@ impl Core {
/// are read *before* the restore — a restored node reappears in a listing the
/// daemon may already have cached, so each destination folder is invalidated
/// and re-enumerated on next access.
fn restore(&self, uids: &[String]) -> Result<usize, String> {
fn restore(&self, uids: &[String]) -> CoreResult<usize> {
let parsed = Self::parse_uids(uids)?;
let parents: Vec<NodeUid> = self
.rt
.block_on(self.client.enumerate_nodes(&parsed))
.map_err(|e| format!("enumerate nodes: {e}"))?
.map_err(|e| CoreError::remote(format!("enumerate nodes: {e}")))?
.into_iter()
.filter_map(|n| n.parent_uid)
.collect();
self.rt
.block_on(self.client.restore_nodes(&parsed))
.map_err(|e| format!("restore: {e}"))?;
.map_err(|e| CoreError::remote(format!("restore: {e}")))?;
{
let mut st = self.state.lock();
for parent in parents {
@@ -2336,11 +2348,11 @@ impl Core {
/// Permanently delete trashed nodes. Irreversible on the server; locally it
/// drops any metadata and cached content the node still owns.
fn delete_forever(&self, uids: &[String]) -> Result<usize, String> {
fn delete_forever(&self, uids: &[String]) -> CoreResult<usize> {
let parsed = Self::parse_uids(uids)?;
self.rt
.block_on(self.client.delete_nodes(&parsed))
.map_err(|e| format!("delete: {e}"))?;
.map_err(|e| CoreError::remote(format!("delete: {e}")))?;
self.drop_local(&parsed);
self.invalidate_trash();
Ok(parsed.len())
@@ -2349,14 +2361,14 @@ impl Core {
/// Permanently delete everything in the trash. The uids are listed first so
/// the blobs of items trashed by *another* client — which this daemon may
/// still hold in its cache — are reclaimed too, not just the ones it trashed.
fn empty_trash(&self) -> Result<usize, String> {
fn empty_trash(&self) -> CoreResult<usize> {
let uids = self
.rt
.block_on(self.client.enumerate_trash_node_uids())
.map_err(|e| format!("enumerate trash: {e}"))?;
.map_err(|e| CoreError::remote(format!("enumerate trash: {e}")))?;
self.rt
.block_on(self.client.empty_trash())
.map_err(|e| format!("empty trash: {e}"))?;
.map_err(|e| CoreError::remote(format!("empty trash: {e}")))?;
self.drop_local(&uids);
self.invalidate_trash();
Ok(uids.len())
@@ -2378,25 +2390,23 @@ impl Core {
/// Create a folder named `name` under the mountpoint-relative `parent_rel`.
/// Interns the new node directly so it shows up without a re-enumeration.
fn create_folder(&self, parent_rel: &Path, name: &str) -> Result<String, String> {
fn create_folder(&self, parent_rel: &Path, name: &str) -> CoreResult<String> {
if name.is_empty() || name.contains('/') {
return Err(format!("invalid name: {name:?}"));
return Err(CoreError::invalid(format!("invalid name: {name:?}")));
}
let (pino, parent_uid) = self
.resolve_path(parent_rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
let (pino, parent_uid) = self.resolve(parent_rel)?;
self.ensure_children(pino)
.map_err(|e| format!("enumerate: {e:?}"))?;
.map_err(|e| CoreError::remote(format!("enumerate: {e:?}")))?;
let new_uid = self
.rt
.block_on(
self.client
.create_folder(&parent_uid, name, Some(now_secs())),
)
.map_err(|e| format!("create folder: {e}"))?;
.map_err(|e| CoreError::remote(format!("create folder: {e}")))?;
let node = self
.fetch_node(&new_uid)
.map_err(|e| format!("fetch node: {e:?}"))?;
.map_err(|e| CoreError::remote(format!("fetch node: {e:?}")))?;
let mut st = self.state.lock();
let ino = st.intern(pino, node);
if let Some(kids) = st.children.get_mut(&pino)
@@ -2409,15 +2419,13 @@ impl Core {
/// Upload a file named `name` with content `bytes` into the
/// mountpoint-relative `parent_rel` folder. Interns the new node directly.
fn upload(&self, parent_rel: &Path, name: &str, bytes: &[u8]) -> Result<String, String> {
fn upload(&self, parent_rel: &Path, name: &str, bytes: &[u8]) -> CoreResult<String> {
if name.is_empty() || name.contains('/') {
return Err(format!("invalid name: {name:?}"));
return Err(CoreError::invalid(format!("invalid name: {name:?}")));
}
let (pino, parent_uid) = self
.resolve_path(parent_rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
let (pino, parent_uid) = self.resolve(parent_rel)?;
self.ensure_children(pino)
.map_err(|e| format!("enumerate: {e:?}"))?;
.map_err(|e| CoreError::remote(format!("enumerate: {e:?}")))?;
let guard = self
.transfers
.begin(name, "", TransferDirection::Upload, bytes.len() as u64);
@@ -2434,11 +2442,11 @@ impl Core {
None,
false,
))
.map_err(|e| format!("upload: {e}"))?;
.map_err(|e| CoreError::remote(format!("upload: {e}")))?;
drop(guard);
let node = self
.fetch_node(&new_uid)
.map_err(|e| format!("fetch node: {e:?}"))?;
.map_err(|e| CoreError::remote(format!("fetch node: {e:?}")))?;
let mut st = self.state.lock();
let ino = st.intern(pino, node);
if let Some(kids) = st.children.get_mut(&pino)
@@ -2457,12 +2465,10 @@ impl Core {
/// thread — a large tree far outlasts the control socket's read timeout — so
/// it reports only a summary for the log. Individual failures are counted and
/// logged rather than aborting the whole batch.
fn upload_paths(&self, parent_rel: &Path, sources: &[PathBuf]) -> Result<UploadStats, String> {
let (pino, parent_uid) = self
.resolve_path(parent_rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
fn upload_paths(&self, parent_rel: &Path, sources: &[PathBuf]) -> CoreResult<UploadStats> {
let (pino, parent_uid) = self.resolve(parent_rel)?;
self.ensure_children(pino)
.map_err(|e| format!("enumerate: {e:?}"))?;
.map_err(|e| CoreError::remote(format!("enumerate: {e:?}")))?;
// Phase 1 (sequential): build the remote folder skeleton and collect the
// flat list of files to upload. Folders must exist before their children,
@@ -2552,12 +2558,12 @@ impl Core {
pino: u64,
parent_uid: &NodeUid,
name: &str,
) -> Result<(u64, NodeUid), String> {
) -> CoreResult<(u64, NodeUid)> {
if name.is_empty() || name.contains('/') {
return Err(format!("invalid folder name: {name:?}"));
return Err(CoreError::invalid(format!("invalid folder name: {name:?}")));
}
self.ensure_children(pino)
.map_err(|e| format!("enumerate: {e:?}"))?;
.map_err(|e| CoreError::remote(format!("enumerate: {e:?}")))?;
{
let st = self.state.lock();
if let Some(kids) = st.children.get(&pino) {
@@ -2577,10 +2583,10 @@ impl Core {
self.client
.create_folder(parent_uid, name, Some(now_secs())),
)
.map_err(|e| format!("create folder {name}: {e}"))?;
.map_err(|e| CoreError::remote(format!("create folder {name}: {e}")))?;
let node = self
.fetch_node(&new_uid)
.map_err(|e| format!("fetch node: {e:?}"))?;
.map_err(|e| CoreError::remote(format!("fetch node: {e:?}")))?;
let mut st = self.state.lock();
let ino = st.intern(pino, node);
if let Some(kids) = st.children.get_mut(&pino)
@@ -2605,13 +2611,13 @@ impl Core {
tasks: &mut Vec<UploadTask>,
folders: &mut usize,
job: &JobGuard,
) -> Result<(), String> {
) -> CoreResult<()> {
let meta =
std::fs::symlink_metadata(src).map_err(|e| format!("stat {}: {e}", src.display()))?;
std::fs::symlink_metadata(src).map_err(|e| CoreError::internal(format!("stat {}: {e}", src.display())))?;
let name = src
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| format!("unusable name: {}", src.display()))?
.ok_or_else(|| CoreError::invalid(format!("unusable name: {}", src.display())))?
.to_string();
if meta.is_dir() {
@@ -2619,7 +2625,7 @@ impl Core {
let (child_ino, child_uid) = self.ensure_remote_folder(pino, parent_uid, &name)?;
*folders += 1;
let mut entries: Vec<PathBuf> = std::fs::read_dir(src)
.map_err(|e| format!("read dir {}: {e}", src.display()))?
.map_err(|e| CoreError::internal(format!("read dir {}: {e}", src.display())))?
.filter_map(|e| e.ok())
.map(|e| e.path())
.collect();
@@ -2636,7 +2642,7 @@ impl Core {
job.detail(&name);
} else if meta.is_file() {
if name.contains('/') {
return Err(format!("invalid file name: {name:?}"));
return Err(CoreError::invalid(format!("invalid file name: {name:?}")));
}
tasks.push(UploadTask {
parent_ino: pino,
@@ -2660,14 +2666,16 @@ impl Core {
&self,
kind: ActivityKind,
target: impl Into<String>,
detail: impl Into<String>,
// `Display` rather than `Into<String>` so a classified `CoreError` can be
// logged as-is, without the caller flattening it first.
detail: impl std::fmt::Display,
ok: bool,
) {
let entry = ActivityEntry {
time: now_secs(),
kind,
target: target.into(),
detail: detail.into(),
detail: detail.to_string(),
ok,
};
if let Err(e) = self.db.activity_add(&entry) {
@@ -2773,12 +2781,12 @@ fn role_to_str(role: MemberRole) -> &'static str {
/// Parse a wire role string into a [`MemberRole`]. "inherited" is read-only and
/// rejected here, since it cannot be sent when inviting or updating.
fn role_from_str(role: &str) -> Result<MemberRole, String> {
fn role_from_str(role: &str) -> CoreResult<MemberRole> {
match role.to_lowercase().as_str() {
"viewer" => Ok(MemberRole::Viewer),
"editor" => Ok(MemberRole::Editor),
"admin" => Ok(MemberRole::Admin),
other => Err(format!("invalid role: {other}")),
other => Err(CoreError::invalid(format!("invalid role: {other}"))),
}
}
+13 -12
View File
@@ -8,6 +8,7 @@
use std::collections::HashMap;
use std::path::PathBuf;
use pdfs_core::{CoreError, CoreResult};
use pdfs_core::control::{PhotoItem, PhotoKind, PhotoThumb};
use pdfs_core::db::{self, StoredPhoto};
use std::sync::atomic::Ordering;
@@ -28,8 +29,8 @@ impl Core {
limit: usize,
kind: Option<PhotoKind>,
range: Option<(i64, i64)>,
) -> Result<Option<Vec<PhotoItem>>, String> {
let count = self.db.photos_count().map_err(|e| e.to_string())?;
) -> CoreResult<Option<Vec<PhotoItem>>> {
let count = self.db.photos_count().map_err(CoreError::from)?;
if count == 0 {
// Nothing to serve, so this one request has to wait for the fetch —
// unless we already know the account has no photos volume and the
@@ -48,7 +49,7 @@ impl Core {
let page = self
.db
.photos_page(offset, limit, kind, range)
.map_err(|e| e.to_string())?;
.map_err(CoreError::from)?;
Ok(Some(page.into_iter().map(|p| self.photo_item(p)).collect()))
}
@@ -269,12 +270,12 @@ impl Core {
/// Whether the listing stamped under `key` is older than `ttl` (or was never
/// fetched).
pub(crate) async fn refresh_timeline(&self) -> Result<bool, String> {
pub(crate) async fn refresh_timeline(&self) -> CoreResult<bool> {
let photos = self.photos();
if photos
.get_photos_root()
.await
.map_err(|e| format!("photos root: {e}"))?
.map_err(|e| CoreError::remote(format!("photos root: {e}")))?
.is_none()
{
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 0);
@@ -284,7 +285,7 @@ impl Core {
let items = photos
.enumerate_timeline()
.await
.map_err(|e| format!("timeline: {e}"))?;
.map_err(|e| CoreError::remote(format!("timeline: {e}")))?;
// The timeline DTO carries only a uid and capture time, but the Photos
// page has to split into Photos / Videos / Raw — which needs each photo's
@@ -317,7 +318,7 @@ impl Core {
(key, it.capture_time, name, media_type)
})
.collect();
self.db.photos_replace(&rows).map_err(|e| e.to_string())?;
self.db.photos_replace(&rows).map_err(CoreError::from)?;
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 1);
let _ = self.db.set_state_i64(PHOTOS_SYNCED_MS, now_ms());
Ok(true)
@@ -340,23 +341,23 @@ impl Core {
/// Download a photo's full content into the content cache, returning its
/// on-disk path (served from cache when a fresh blob already exists).
pub(crate) fn open_photo(&self, uid: &NodeUid) -> Result<PathBuf, String> {
pub(crate) fn open_photo(&self, uid: &NodeUid) -> CoreResult<PathBuf> {
let photos = self.photos();
let node = self
.rt
.block_on(photos.get_node(uid))
.map_err(|e| format!("photo node: {e}"))?
.ok_or("photo not found")?;
.map_err(|e| CoreError::remote(format!("photo node: {e}")))?
.ok_or_else(|| CoreError::not_found("photo not found"))?;
let (mtime, size) = (node.modification_time, node_size(&node));
if let Some(p) = self.cache.cached_content_path(uid, mtime, size) {
return Ok(p);
}
let bytes = self
.download_photo_tracked(&photos, uid, &node.name, size)
.map_err(|e| format!("download photo: {e}"))?;
.map_err(|e| CoreError::remote(format!("download photo: {e}")))?;
self.cache
.store(uid, mtime, size, &bytes)
.map_err(|e| format!("cache store: {e}"))?;
.map_err(|e| CoreError::internal(format!("cache store: {e}")))?;
Ok(self.cache.content_path(uid))
}
+59 -70
View File
@@ -8,6 +8,7 @@
use std::path::Path;
use pdfs_core::{CoreError, CoreResult};
use pdfs_core::control::{
BookmarkInfo, DirEntry, InvitationInfo, PublicLinkInfo, ShareEntry, ShareEntryKind, SharedItem,
};
@@ -27,29 +28,25 @@ impl Core {
emails: &[String],
role: &str,
message: Option<&str>,
) -> Result<(usize, usize), String> {
let (_ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
) -> CoreResult<(usize, usize)> {
let (_ino, uid) = self.resolve(rel)?;
let role = role_from_str(role)?;
let invitees: Vec<(String, MemberRole)> =
emails.iter().map(|e| (e.clone(), role)).collect();
self.rt
.block_on(self.client.invite_users(&uid, &invitees, message))
.map_err(|e| format!("share: {e}"))
.map_err(|e| CoreError::remote(format!("share: {e}")))
}
/// List the members, pending invitations and public link of the node at `rel`.
pub(crate) fn list_share(&self, rel: &Path) -> Result<(Vec<ShareEntry>, Option<PublicLinkInfo>), String> {
let (_ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
pub(crate) fn list_share(&self, rel: &Path) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
let (_ino, uid) = self.resolve(rel)?;
let mut entries = Vec::new();
for m in self
.rt
.block_on(self.client.list_share_members(&uid))
.map_err(|e| format!("list members: {e}"))?
.map_err(|e| CoreError::remote(format!("list members: {e}")))?
{
entries.push(ShareEntry {
id: m.membership_id.to_string(),
@@ -61,7 +58,7 @@ impl Core {
for inv in self
.rt
.block_on(self.client.list_share_invitations(&uid))
.map_err(|e| format!("list invitations: {e}"))?
.map_err(|e| CoreError::remote(format!("list invitations: {e}")))?
{
entries.push(ShareEntry {
id: inv.invitation_id,
@@ -73,7 +70,7 @@ impl Core {
for ext in self
.rt
.block_on(self.client.list_external_invitations(&uid))
.map_err(|e| format!("list external invitations: {e}"))?
.map_err(|e| CoreError::remote(format!("list external invitations: {e}")))?
{
entries.push(ShareEntry {
id: ext.invitation_id,
@@ -86,7 +83,7 @@ impl Core {
let link = self
.rt
.block_on(self.client.get_public_link(&uid))
.map_err(|e| format!("get public link: {e}"))?
.map_err(|e| CoreError::remote(format!("get public link: {e}")))?
.map(public_link_info);
Ok((entries, link))
@@ -100,84 +97,80 @@ impl Core {
id: &str,
kind: ShareEntryKind,
role: &str,
) -> Result<(), String> {
let (_ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
) -> CoreResult<()> {
let (_ino, uid) = self.resolve(rel)?;
let role = role_from_str(role)?;
match kind {
ShareEntryKind::Member => {
let member = self
.rt
.block_on(self.client.list_share_members(&uid))
.map_err(|e| format!("list members: {e}"))?
.map_err(|e| CoreError::remote(format!("list members: {e}")))?
.into_iter()
.find(|m| m.membership_id.to_string() == id)
.ok_or_else(|| "member not found".to_string())?;
.ok_or_else(|| CoreError::not_found("member not found"))?;
self.rt
.block_on(self.client.update_member_role(&member, role))
.map_err(|e| format!("update role: {e}"))
.map_err(|e| CoreError::remote(format!("update role: {e}")))
}
ShareEntryKind::ProtonInvite => {
let inv = self
.rt
.block_on(self.client.list_share_invitations(&uid))
.map_err(|e| format!("list invitations: {e}"))?
.map_err(|e| CoreError::remote(format!("list invitations: {e}")))?
.into_iter()
.find(|i| i.invitation_id == id)
.ok_or_else(|| "invitation not found".to_string())?;
.ok_or_else(|| CoreError::not_found("invitation not found"))?;
self.rt
.block_on(self.client.update_invitation_role(&inv, role))
.map_err(|e| format!("update role: {e}"))
.map_err(|e| CoreError::remote(format!("update role: {e}")))
}
ShareEntryKind::ExternalInvite => {
Err("an external invitation's role cannot be changed".to_string())
Err(CoreError::invalid("an external invitation's role cannot be changed"))
}
}
}
/// Remove a member, pending Proton invite, or external invite from the node
/// at `rel`.
pub(crate) fn remove_share_entry(&self, rel: &Path, id: &str, kind: ShareEntryKind) -> Result<(), String> {
let (_ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
pub(crate) fn remove_share_entry(&self, rel: &Path, id: &str, kind: ShareEntryKind) -> CoreResult<()> {
let (_ino, uid) = self.resolve(rel)?;
match kind {
ShareEntryKind::Member => {
let member = self
.rt
.block_on(self.client.list_share_members(&uid))
.map_err(|e| format!("list members: {e}"))?
.map_err(|e| CoreError::remote(format!("list members: {e}")))?
.into_iter()
.find(|m| m.membership_id.to_string() == id)
.ok_or_else(|| "member not found".to_string())?;
.ok_or_else(|| CoreError::not_found("member not found"))?;
self.rt
.block_on(self.client.remove_member(&member))
.map_err(|e| format!("remove member: {e}"))
.map_err(|e| CoreError::remote(format!("remove member: {e}")))
}
ShareEntryKind::ProtonInvite => {
let inv = self
.rt
.block_on(self.client.list_share_invitations(&uid))
.map_err(|e| format!("list invitations: {e}"))?
.map_err(|e| CoreError::remote(format!("list invitations: {e}")))?
.into_iter()
.find(|i| i.invitation_id == id)
.ok_or_else(|| "invitation not found".to_string())?;
.ok_or_else(|| CoreError::not_found("invitation not found"))?;
self.rt
.block_on(self.client.delete_invitation(&inv))
.map_err(|e| format!("revoke invitation: {e}"))
.map_err(|e| CoreError::remote(format!("revoke invitation: {e}")))
}
ShareEntryKind::ExternalInvite => {
let ext = self
.rt
.block_on(self.client.list_external_invitations(&uid))
.map_err(|e| format!("list external invitations: {e}"))?
.map_err(|e| CoreError::remote(format!("list external invitations: {e}")))?
.into_iter()
.find(|i| i.invitation_id == id)
.ok_or_else(|| "external invitation not found".to_string())?;
.ok_or_else(|| CoreError::not_found("external invitation not found"))?;
self.rt
.block_on(self.client.delete_external_invitation(&ext))
.map_err(|e| format!("revoke external invitation: {e}"))
.map_err(|e| CoreError::remote(format!("revoke external invitation: {e}")))
}
}
}
@@ -189,10 +182,8 @@ impl Core {
role: &str,
password: Option<&str>,
expires: Option<i64>,
) -> Result<PublicLinkInfo, String> {
let (_ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
) -> CoreResult<PublicLinkInfo> {
let (_ino, uid) = self.resolve(rel)?;
let role = role_from_str(role)?;
let link = self
.rt
@@ -200,41 +191,39 @@ impl Core {
self.client
.create_public_link(&uid, role, password, expires),
)
.map_err(|e| format!("create public link: {e}"))?;
.map_err(|e| CoreError::remote(format!("create public link: {e}")))?;
Ok(public_link_info(link))
}
/// Remove the public link `id` from the node at `rel`.
pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> Result<(), String> {
let (_ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> CoreResult<()> {
let (_ino, uid) = self.resolve(rel)?;
let link = self
.rt
.block_on(self.client.get_public_link(&uid))
.map_err(|e| format!("get public link: {e}"))?
.map_err(|e| CoreError::remote(format!("get public link: {e}")))?
.filter(|l| l.public_link_id == id)
.ok_or_else(|| "public link not found".to_string())?;
.ok_or_else(|| CoreError::not_found("public link not found"))?;
self.rt
.block_on(self.client.remove_public_link(&link))
.map_err(|e| format!("remove public link: {e}"))
.map_err(|e| CoreError::remote(format!("remove public link: {e}")))
}
// ---- shared with me ---------------------------------------------------
/// List nodes shared with me that I have accepted.
pub(crate) fn list_shared_with_me(&self) -> Result<Vec<DirEntry>, String> {
pub(crate) fn list_shared_with_me(&self) -> CoreResult<Vec<DirEntry>> {
let uids = self
.rt
.block_on(self.client.enumerate_shared_with_me_node_uids())
.map_err(|e| format!("enumerate shared: {e}"))?;
.map_err(|e| CoreError::remote(format!("enumerate shared: {e}")))?;
if uids.is_empty() {
return Ok(Vec::new());
}
let nodes = self
.rt
.block_on(self.client.enumerate_nodes(&uids))
.map_err(|e| format!("enumerate nodes: {e}"))?;
.map_err(|e| CoreError::remote(format!("enumerate nodes: {e}")))?;
Ok(nodes
.into_iter()
.map(|n| {
@@ -262,21 +251,21 @@ impl Core {
}
/// Leave a shared node by its uid.
pub(crate) fn leave_shared(&self, uid: &str) -> Result<(), String> {
let uid = parse_uid(uid).ok_or_else(|| format!("invalid uid: {uid}"))?;
pub(crate) fn leave_shared(&self, uid: &str) -> CoreResult<()> {
let uid = parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?;
self.rt
.block_on(self.client.leave_shared_node(&uid))
.map_err(|e| format!("leave shared: {e}"))
.map_err(|e| CoreError::remote(format!("leave shared: {e}")))
}
// ---- incoming invitations ---------------------------------------------
/// List invitations addressed to me, pending accept or reject.
pub(crate) fn list_invitations(&self) -> Result<Vec<InvitationInfo>, String> {
pub(crate) fn list_invitations(&self) -> CoreResult<Vec<InvitationInfo>> {
let invitations = self
.rt
.block_on(self.client.list_incoming_invitations())
.map_err(|e| format!("list invitations: {e}"))?;
.map_err(|e| CoreError::remote(format!("list invitations: {e}")))?;
Ok(invitations
.into_iter()
.map(|i| InvitationInfo {
@@ -289,27 +278,27 @@ impl Core {
}
/// Accept an invitation addressed to me by its id.
pub(crate) fn accept_invitation(&self, id: &str) -> Result<(), String> {
pub(crate) fn accept_invitation(&self, id: &str) -> CoreResult<()> {
self.rt
.block_on(self.client.accept_invitation(id))
.map_err(|e| format!("accept invitation: {e}"))
.map_err(|e| CoreError::remote(format!("accept invitation: {e}")))
}
/// Reject an invitation addressed to me by its id.
pub(crate) fn reject_invitation(&self, id: &str) -> Result<(), String> {
pub(crate) fn reject_invitation(&self, id: &str) -> CoreResult<()> {
self.rt
.block_on(self.client.reject_invitation(id))
.map_err(|e| format!("reject invitation: {e}"))
.map_err(|e| CoreError::remote(format!("reject invitation: {e}")))
}
// ---- bookmarks --------------------------------------------------------
/// List public links saved to my account.
pub(crate) fn list_bookmarks(&self) -> Result<Vec<BookmarkInfo>, String> {
pub(crate) fn list_bookmarks(&self) -> CoreResult<Vec<BookmarkInfo>> {
let bookmarks = self
.rt
.block_on(self.client.list_bookmarks())
.map_err(|e| format!("list bookmarks: {e}"))?;
.map_err(|e| CoreError::remote(format!("list bookmarks: {e}")))?;
Ok(bookmarks
.into_iter()
.map(|b| BookmarkInfo {
@@ -322,17 +311,17 @@ impl Core {
}
/// Save a public link (optionally password-protected) as a bookmark.
pub(crate) fn create_bookmark(&self, url: &str, password: Option<&str>) -> Result<(), String> {
pub(crate) fn create_bookmark(&self, url: &str, password: Option<&str>) -> CoreResult<()> {
self.rt
.block_on(self.client.create_bookmark(url, password))
.map_err(|e| format!("create bookmark: {e}"))
.map_err(|e| CoreError::remote(format!("create bookmark: {e}")))
}
/// Remove a saved bookmark by its token.
pub(crate) fn delete_bookmark(&self, token: &str) -> Result<(), String> {
pub(crate) fn delete_bookmark(&self, token: &str) -> CoreResult<()> {
self.rt
.block_on(self.client.delete_bookmark(token))
.map_err(|e| format!("delete bookmark: {e}"))
.map_err(|e| CoreError::remote(format!("delete bookmark: {e}")))
}
// ---- shared by me -----------------------------------------------------
@@ -342,18 +331,18 @@ impl Core {
/// the shared uids; the per-node detail is then gathered best-effort — a single
/// node racing with an unshare drops from the list rather than failing the whole
/// request.
pub(crate) fn list_shared_by_me(&self) -> Result<Vec<SharedItem>, String> {
pub(crate) fn list_shared_by_me(&self) -> CoreResult<Vec<SharedItem>> {
let uids = self
.rt
.block_on(self.client.enumerate_shared_by_me_node_uids())
.map_err(|e| format!("enumerate shared-by-me: {e}"))?;
.map_err(|e| CoreError::remote(format!("enumerate shared-by-me: {e}")))?;
if uids.is_empty() {
return Ok(Vec::new());
}
let nodes = self
.rt
.block_on(self.client.enumerate_nodes(&uids))
.map_err(|e| format!("enumerate nodes: {e}"))?;
.map_err(|e| CoreError::remote(format!("enumerate nodes: {e}")))?;
let mut items = Vec::with_capacity(nodes.len());
for n in nodes {
let uid = n.uid.clone();
+1 -1
View File
@@ -12,7 +12,7 @@ path = "src/main.rs"
[[bin]]
name = "pdfs-app"
path = "src/app.rs"
path = "src/app/main.rs"
[[bin]]
name = "pdfs-prompt"
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+305
View File
@@ -0,0 +1,305 @@
use crate::*;
pub(crate) struct ActivityState {
// Activity page: one section, newest-first, rebuilt wholesale on each load.
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) retry: gtk4::Button,
pub(crate) group: adw::PreferencesGroup,
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
pub(crate) inflight: Cell<bool>,
/// Fingerprint of the feed currently on screen (see [`activity_key`]). The
/// page polls every couple of seconds and usually gets back exactly what it
/// is already showing; rebuilding all 200 rows for that would throw away the
/// user's scroll position several times a minute.
pub(crate) key: RefCell<Option<String>>,
}
/// Widgets the Activity page's load/repaint touch.
pub(crate) struct ActivityWidgets {
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) group: adw::PreferencesGroup,
pub(crate) retry: gtk4::Button,
pub(crate) refresh: gtk4::Button,
}
/// The Activity page: a newest-first feed of the mutations and transfers the
/// daemon performed this session (uploads, deletes, shares, …).
pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
let title = gtk4::Label::builder()
.label("Activity")
.halign(gtk4::Align::Start)
.hexpand(true)
.build();
title.add_css_class("title-2");
let refresh = refresh_button();
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header.set_hexpand(true);
header.append(&title);
header.append(&refresh);
let group = adw::PreferencesGroup::new();
let clamp = adw::Clamp::builder().child(&group).build();
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.child(&clamp)
.build();
let retry = gtk4::Button::builder()
.label("Retry")
.halign(gtk4::Align::Center)
.build();
retry.add_css_class("pill");
retry.add_css_class("suggested-action");
retry.set_visible(false);
let status = adw::StatusPage::builder()
.icon_name("document-open-recent-symbolic")
.vexpand(true)
.child(&retry)
.build();
status.add_css_class("compact");
let content = gtk4::Stack::new();
content.set_vexpand(true);
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
content.add_named(&scroll, Some("list"));
content.add_named(&status, Some("status"));
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12);
inner.set_margin_bottom(12);
inner.set_margin_start(12);
inner.set_margin_end(12);
inner.append(&header);
inner.append(&content);
(
inner.upcast(),
ActivityWidgets {
content,
status,
group,
retry,
refresh,
},
)
}
/// Install the Activity page's retry button.
pub(crate) fn wire_activity(ui: &Rc<Ui>, retry: &gtk4::Button) {
let ui_retry = ui.clone();
retry.connect_clicked(move |_| {
service::restart();
load_activity(&ui_retry);
});
}
/// Show a status page in place of the Activity list.
pub(crate) fn activity_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
ui.activity.status.set_icon_name(Some(icon));
ui.activity.status.set_title(title);
ui.activity.status.set_description(Some(description));
ui.activity.retry.set_visible(retry);
ui.activity.content.set_visible_child_name("status");
// The list is no longer what is on screen, so the next repaint must not skip
// itself as a no-op and leave this status view up.
*ui.activity.key.borrow_mut() = None;
}
/// Refresh the Activity feed in place, with no status flash and no spinner.
/// Driven by the periodic tick while the page is on screen, so a running sync
/// pass fills the feed as it works rather than only once it is done. Anything
/// other than a good answer leaves the rows alone until the next tick.
pub(crate) fn refresh_activity(ui: &Rc<Ui>) {
if ui.activity.inflight.get() {
return;
}
ui.activity.inflight.set(true);
let rx = spawn_request(
ui.dirs.control_socket(),
Request::ListActivity { limit: 200 },
);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.activity.inflight.set(false);
// The page may have been navigated away from while the request was in
// flight.
if let Ok(Ok(Response::Activity { items })) = result
&& ui.stack.visible_child_name().as_deref() == Some("activity")
{
repaint_activity(&ui, &items);
}
});
}
pub(crate) fn load_activity(ui: &Rc<Ui>) {
if ui.activity.inflight.get() {
return;
}
ui.activity.inflight.set(true);
activity_status(
ui,
"document-open-recent-symbolic",
"Loading…",
"Reading recent activity.",
false,
);
ui.busy_begin();
let rx = spawn_request(
ui.dirs.control_socket(),
Request::ListActivity { limit: 200 },
);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
ui.activity.inflight.set(false);
match result {
Ok(Ok(Response::Activity { items })) => repaint_activity(&ui, &items),
Ok(Ok(Response::Error { message, .. })) => activity_status(
&ui,
"dialog-warning-symbolic",
"Couldn't read activity",
&message,
false,
),
Ok(Ok(_)) => activity_status(
&ui,
"dialog-warning-symbolic",
"Couldn't read activity",
"Unexpected reply from the mount service.",
false,
),
Ok(Err(_)) | Err(_) => activity_unreachable(&ui),
}
});
}
/// The daemon didn't answer the Activity page.
pub(crate) fn activity_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() {
activity_status(
ui,
"network-offline-symbolic",
"Not connected",
"The Proton Drive mount service isn't running.",
true,
);
return;
}
activity_status(
ui,
"folder-remote-symbolic",
"Connecting…",
"Waiting for the Proton Drive mount service to come up.",
false,
);
let ui = ui.clone();
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
if ui.stack.visible_child_name().as_deref() == Some("activity") {
load_activity(&ui);
}
});
}
/// A cheap fingerprint of a feed: how many entries, and what the newest one is.
/// The log is append-only and newest-first, so a feed with the same length and
/// the same newest entry is the same feed.
pub(crate) fn activity_key(items: &[ActivityEntry]) -> String {
match items.first() {
Some(a) => format!("{}:{}:{}:{}", items.len(), a.time, a.target, a.detail),
None => String::new(),
}
}
/// Rebuild the Activity section from a fresh log, unless it is already showing
/// exactly this feed.
pub(crate) fn repaint_activity(ui: &Rc<Ui>, items: &[ActivityEntry]) {
let key = activity_key(items);
if ui.activity.key.borrow().as_deref() == Some(key.as_str()) {
return;
}
*ui.activity.key.borrow_mut() = Some(key);
for row in ui.activity.rows.borrow_mut().drain(..) {
ui.activity.group.remove(&row);
}
if items.is_empty() {
activity_status(
ui,
"document-open-recent-symbolic",
"Nothing yet",
"Uploads, moves, shares and other changes appear here as they happen.",
false,
);
return;
}
ui.activity.content.set_visible_child_name("list");
let mut rows: Vec<gtk4::Widget> = Vec::new();
for a in items {
let mut subtitle = activity_time(a.time);
if !a.detail.is_empty() {
subtitle = format!("{subtitle} · {}", a.detail);
}
let row = adw::ActionRow::builder()
.title(format!("{} {}", activity_verb(a.kind), a.target))
.subtitle(subtitle)
.build();
let icon = if a.ok {
activity_icon(a.kind)
} else {
"dialog-warning-symbolic"
};
row.add_prefix(&gtk4::Image::from_icon_name(icon));
row.set_activatable(false);
ui.activity.group.add(&row);
rows.push(row.upcast());
}
*ui.activity.rows.borrow_mut() = rows;
}
/// A human verb for an activity kind, used as the row title's lead word.
pub(crate) fn activity_verb(kind: ActivityKind) -> &'static str {
match kind {
ActivityKind::Upload => "Uploaded",
ActivityKind::Download => "Downloaded",
ActivityKind::Sync => "Synced",
ActivityKind::Rename => "Renamed",
ActivityKind::Move => "Moved",
ActivityKind::CreateFolder => "Created folder",
ActivityKind::Trash => "Trashed",
ActivityKind::Restore => "Restored",
ActivityKind::DeleteForever => "Deleted",
ActivityKind::EmptyTrash => "Emptied trash —",
ActivityKind::Share => "Shared",
ActivityKind::PublicLink => "Linked",
ActivityKind::Unshare => "Unshared",
}
}
/// A themed icon for an activity kind.
pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
match kind {
ActivityKind::Upload => "document-send-symbolic",
ActivityKind::Download => "document-save-symbolic",
ActivityKind::Sync => "emblem-synchronizing-symbolic",
ActivityKind::Rename => "document-edit-symbolic",
ActivityKind::Move => "go-jump-symbolic",
ActivityKind::CreateFolder => "folder-new-symbolic",
ActivityKind::Trash | ActivityKind::DeleteForever | ActivityKind::EmptyTrash => {
"user-trash-symbolic"
}
ActivityKind::Restore => "edit-undo-symbolic",
ActivityKind::Share | ActivityKind::PublicLink => "emblem-shared-symbolic",
ActivityKind::Unshare => "action-unavailable-symbolic",
}
}
/// Format an epoch-seconds timestamp for the Activity feed, in local time.
pub(crate) fn activity_time(secs: i64) -> String {
glib::DateTime::from_unix_local(secs)
.and_then(|dt| dt.format("%b %-d, %H:%M"))
.map(|s| s.to_string())
.unwrap_or_default()
}
File diff suppressed because it is too large. Load diff
+719
View File
@@ -0,0 +1,719 @@
use crate::*;
pub(crate) struct DevicesState {
// Devices page: two sections (synced folders + other devices), rebuilt
// wholesale on each load.
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) retry: gtk4::Button,
pub(crate) group: adw::PreferencesGroup,
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
pub(crate) sync_group: adw::PreferencesGroup,
pub(crate) sync_rows: RefCell<Vec<gtk4::Widget>>,
pub(crate) inflight: Cell<bool>,
pub(crate) loaded_at: Cell<Option<Instant>>,
}
/// Widgets the Devices page's load/repaint touch.
pub(crate) struct DevicesWidgets {
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
/// "This computer" — the local folders synced to this machine's device.
pub(crate) sync_group: adw::PreferencesGroup,
/// "Other computers" — the account's *other* registered devices. This
/// machine's own device is deliberately not among them; see
/// [`repaint_devices`].
pub(crate) group: adw::PreferencesGroup,
pub(crate) add_folder: gtk4::Button,
pub(crate) retry: gtk4::Button,
pub(crate) refresh: gtk4::Button,
}
/// The Computers page: a "This computer" section listing the local folders synced
/// to this machine's device (each row offering a mode toggle and Remove), plus an
/// "Other devices" section listing the account's other registered devices.
pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
let title = gtk4::Label::builder()
.label("Computers")
.halign(gtk4::Align::Start)
.build();
title.add_css_class("title-2");
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
titles.set_hexpand(true);
titles.append(&title);
let add_folder = gtk4::Button::builder()
.label("Add Folder")
.valign(gtk4::Align::Center)
.build();
add_folder.add_css_class("flat");
let refresh = refresh_button();
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header.append(&titles);
header.append(&refresh);
header.append(&add_folder);
// The description spells out what the per-row On-demand switch does to the
// local copy, because the switch itself can't: turning it on *deletes* the
// files from this disk, which is not something to discover afterwards.
let sync_group = adw::PreferencesGroup::builder()
.title("This computer")
.description(
"Local folders backed up to this device. Synced folders keep a full copy on \
this disk; on-demand folders keep the files in Proton Drive only and fetch \
them as you open them.",
)
.build();
let group = adw::PreferencesGroup::builder()
.title("Other computers")
.description("Other devices backing up to this account.")
.build();
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
groups.append(&sync_group);
groups.append(&group);
let clamp = adw::Clamp::builder().child(&groups).build();
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.child(&clamp)
.build();
let retry = gtk4::Button::builder()
.label("Retry")
.halign(gtk4::Align::Center)
.build();
retry.add_css_class("pill");
retry.add_css_class("suggested-action");
retry.set_visible(false);
let status = adw::StatusPage::builder()
.icon_name("computer-symbolic")
.vexpand(true)
.child(&retry)
.build();
status.add_css_class("compact");
let content = gtk4::Stack::new();
content.set_vexpand(true);
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
content.add_named(&scroll, Some("list"));
content.add_named(&status, Some("status"));
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12);
inner.set_margin_bottom(12);
inner.set_margin_start(12);
inner.set_margin_end(12);
inner.append(&header);
inner.append(&content);
(
inner.upcast(),
DevicesWidgets {
content,
status,
sync_group,
group,
add_folder,
retry,
refresh,
},
)
}
/// Install the Devices page's retry button and the "Add Folder" action.
pub(crate) fn wire_devices(ui: &Rc<Ui>, retry: &gtk4::Button, add_folder: &gtk4::Button) {
let ui_retry = ui.clone();
retry.connect_clicked(move |_| {
service::restart();
load_devices(&ui_retry);
});
let ui_add = ui.clone();
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
}
/// Show a status page in place of the Devices list.
pub(crate) fn devices_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
ui.devices.status.set_icon_name(Some(icon));
ui.devices.status.set_title(title);
ui.devices.status.set_description(Some(description));
ui.devices.retry.set_visible(retry);
ui.devices.content.set_visible_child_name("status");
}
/// Fetch this machine's synced folders and the account's other devices, then
/// repaint both sections. The two requests are chained so a single unreachable
/// daemon collapses the whole page to a status view.
pub(crate) fn load_devices(ui: &Rc<Ui>) {
if ui.devices.inflight.get() {
return;
}
ui.devices.inflight.set(true);
devices_status(
ui,
"computer-symbolic",
"Loading…",
"Reading your devices.",
false,
);
ui.busy_begin();
// The two lists are independent and the daemon serves requests concurrently,
// so fire both up front and collect them, rather than paying two round trips
// back to back.
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSyncFolders);
let rx2 = spawn_request(ui.dirs.control_socket(), Request::ListDevices);
let ui = ui.clone();
glib::spawn_future_local(async move {
let sync = rx.recv().await;
let devices_reply = rx2.recv().await;
let folders = match sync {
Ok(Ok(Response::SyncFolders { items })) => items,
// The daemon answered but not as expected — treat as empty and carry
// on to the devices list rather than failing the whole page.
Ok(Ok(_)) => Vec::new(),
Ok(Err(_)) | Err(_) => {
ui.busy_end();
ui.devices.inflight.set(false);
ui.devices.loaded_at.set(None);
devices_unreachable(&ui);
return;
}
};
// Daemon reachable: show the list and paint the synced-folders section.
ui.devices.content.set_visible_child_name("list");
repaint_sync_folders(&ui, &folders);
let devices = match devices_reply {
Ok(Ok(Response::Devices { items })) => items,
_ => Vec::new(),
};
ui.busy_end();
ui.devices.inflight.set(false);
repaint_devices(&ui, &devices);
ui.devices.loaded_at.set(Some(Instant::now()));
});
}
/// Refresh just the synced-folders section, with no status flash, no spinner and
/// no devices round-trip. Driven by the periodic tick while the Devices page is
/// on screen: a folder's sync progress is only live if something re-reads it.
///
/// A hiccup leaves the page as it is — the next tick tries again, and a real
/// outage is reported by [`load_devices`] on the next visit.
pub(crate) fn refresh_sync_folders(ui: &Rc<Ui>) {
if ui.devices.inflight.get() {
return;
}
ui.devices.inflight.set(true);
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSyncFolders);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.devices.inflight.set(false);
let Ok(Ok(Response::SyncFolders { items })) = result else {
return;
};
// The page may have been navigated away from, or collapsed to a status
// view, while the request was in flight.
if ui.stack.visible_child_name().as_deref() == Some("devices")
&& ui.devices.content.visible_child_name().as_deref() == Some("list")
{
repaint_sync_folders(&ui, &items);
}
});
}
/// The daemon didn't answer the Devices page.
pub(crate) fn devices_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() {
devices_status(
ui,
"network-offline-symbolic",
"Not connected",
"The Proton Drive mount service isn't running.",
true,
);
return;
}
devices_status(
ui,
"folder-remote-symbolic",
"Connecting…",
"Waiting for the Proton Drive mount service to come up.",
false,
);
let ui = ui.clone();
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
if ui.stack.visible_child_name().as_deref() == Some("devices") {
load_devices(&ui);
}
});
}
/// Rebuild the "Other computers" section from a fresh listing. Empty is a normal
/// state (this machine may be the only device), so it shows a placeholder row
/// rather than collapsing the page.
///
/// This machine's own device is filtered out. Deleting a device deletes its root
/// folder — every file backed up from it — and for *this* machine that would also
/// pull the ground out from under the synced folders listed directly above, so it
/// is not offered as a peer of "remove some other laptop". Removing this
/// computer's backup means removing its folders, each of which asks about its
/// cloud copy on its own terms.
pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
for row in ui.devices.rows.borrow_mut().drain(..) {
ui.devices.group.remove(&row);
}
let others: Vec<&DeviceInfo> = devices.iter().filter(|d| !d.this_device).collect();
if others.is_empty() {
let row = adw::ActionRow::builder()
.title("No other computers")
.subtitle("Desktop apps syncing to this account appear here.")
.build();
row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic"));
ui.devices.group.add(&row);
*ui.devices.rows.borrow_mut() = vec![row.upcast()];
return;
}
let mut rows: Vec<gtk4::Widget> = Vec::new();
for dev in others {
let row = adw::ActionRow::builder()
.title(&dev.name)
.subtitle(device_subtitle(dev))
.build();
row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic"));
let remove = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Remove this computer and everything it backed up")
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
let rename = gtk4::Button::builder()
.icon_name("document-edit-symbolic")
.tooltip_text("Rename computer")
.valign(gtk4::Align::Center)
.build();
rename.add_css_class("flat");
let ui_ren = ui.clone();
let uid_ren = dev.uid.clone();
let name_ren = dev.name.clone();
rename.connect_clicked(move |_| prompt_rename_device(&ui_ren, &uid_ren, &name_ren));
let ui_rm = ui.clone();
let uid_rm = dev.uid.clone();
let name_rm = dev.name.clone();
remove.connect_clicked(move |_| prompt_remove_device(&ui_rm, &uid_rm, &name_rm));
row.add_suffix(&rename);
row.add_suffix(&remove);
ui.devices.group.add(&row);
rows.push(row.upcast());
}
*ui.devices.rows.borrow_mut() = rows;
}
/// A device row's subtitle: its platform, and when it last synced. A device that
/// has never synced says so — an unexplained missing date reads as a bug, and
/// "never" is the fact that tells the user this computer isn't backing anything up.
pub(crate) fn device_subtitle(dev: &DeviceInfo) -> String {
match dev.last_sync {
Some(secs) if secs > 0 => {
format!("{} · last synced {}", dev.device_type, activity_time(secs))
}
_ => format!("{} · never synced", dev.device_type),
}
}
/// Rebuild the "This computer" section from a fresh synced-folders listing.
pub(crate) fn repaint_sync_folders(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
for row in ui.devices.sync_rows.borrow_mut().drain(..) {
ui.devices.sync_group.remove(&row);
}
if folders.is_empty() {
let row = adw::ActionRow::builder()
.title("No synced folders")
.subtitle("Add a folder to back it up to this device.")
.build();
row.add_prefix(&gtk4::Image::from_icon_name("folder-symbolic"));
ui.devices.sync_group.add(&row);
*ui.devices.sync_rows.borrow_mut() = vec![row.upcast()];
return;
}
let mut rows: Vec<gtk4::Widget> = Vec::new();
for f in folders {
// A running pass describes itself ("Uploading photo.jpg — 12 of 40");
// otherwise fall back to the folder's resting state.
let status = match &f.progress {
Some(p) => sync_progress_label(p),
None => sync_state_label(&f.state).to_string(),
};
// A queued switch is where the folder is *going*, which is what the user
// just asked for and is waiting to see happen — so it leads the subtitle
// instead of the mode the folder is still technically in.
let subtitle = match (f.pending_mode.as_deref(), f.mode.as_str()) {
(Some("ondemand"), _) => format!("Going on-demand · {status}"),
(Some(_), _) => format!("Switching to synced · {status}"),
(None, "ondemand") => format!("On-demand · {status}"),
(None, _) => format!("Synced · {status}"),
};
let row = adw::ActionRow::builder()
.title(&f.local_path)
.subtitle(&subtitle)
.build();
row.add_prefix(&gtk4::Image::from_icon_name("folder-symbolic"));
let id = f.id;
// A pass that knows how far it has got shows it: the subtitle alone
// ("syncing photo.jpg — 12 of 40") never conveys that the end is near. This
// holds for scanning as much as applying — a big folder spends minutes there
// — but a folder syncing for the first time has no estimate to draw against
// (`total == 0`), and this row is repainted on the 2s tick, too slow for a
// pulse to read as motion. So it stays text-only until real counts exist.
if let Some(p) = &f.progress
&& p.total > 0
{
let bar = gtk4::ProgressBar::builder()
.fraction((p.done as f64 / p.total.max(p.done) as f64).min(1.0))
.valign(gtk4::Align::Center)
.width_request(120)
.build();
row.add_suffix(&bar);
}
// Sync now — only meaningful for mirror folders (on-demand has no local
// copy to reconcile). The engine also syncs on file changes and on a
// 120s poll; this is the "don't wait" button.
if f.mode != "ondemand" {
let sync_now = gtk4::Button::builder()
.icon_name("view-refresh-symbolic")
.tooltip_text("Sync this folder now")
.valign(gtk4::Align::Center)
.build();
sync_now.add_css_class("flat");
let ui_sync = ui.clone();
sync_now.connect_clicked(move |_| {
let rx = spawn_request(
ui_sync.dirs.control_socket(),
Request::SyncNow { id: Some(id) },
);
let ui_sync = ui_sync.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => toast(&ui_sync, "Syncing folder…"),
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui_sync, "Couldn't sync", &message, kind)
}
_ => toast_error(
&ui_sync,
"Couldn't sync",
"The mount service didn't respond.",
),
}
});
});
row.add_suffix(&sync_now);
}
// On-demand toggle: off = full local copy (mirror), on = FUSE mount that
// frees the disk. Set the state before wiring the handler so painting the
// current mode doesn't fire a spurious request.
//
// A queued switch paints as already flipped: the daemon accepted the
// request and will act on it, so snapping the switch back to the current
// mode would read as "it didn't take" and invite the user to toggle again.
let target = f.pending_mode.as_deref().unwrap_or(&f.mode);
let ondemand = gtk4::Switch::builder()
.tooltip_text(
"On-demand: free this disk by keeping the files in Proton Drive only, \
fetching each as you open it. Turn off to download them back and keep a \
full local copy.",
)
.valign(gtk4::Align::Center)
.active(target == "ondemand")
.build();
let ui_mode = ui.clone();
ondemand.connect_state_set(move |_, on| {
set_sync_folder_mode(&ui_mode, id, if on { "ondemand" } else { "mirror" });
glib::Propagation::Proceed
});
row.add_suffix(&ondemand);
let remove = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Stop syncing this folder")
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
let ui_rm = ui.clone();
let path = f.local_path.clone();
// The folder's *current* mode, not a queued one: a switch that hasn't
// landed yet has not moved the files anywhere.
let ondemand = f.mode == "ondemand";
remove.connect_clicked(move |_| prompt_remove_sync_folder(&ui_rm, id, &path, ondemand));
row.add_suffix(&remove);
ui.devices.sync_group.add(&row);
rows.push(row.upcast());
}
*ui.devices.sync_rows.borrow_mut() = rows;
}
/// Human label for a synced folder's `state` column.
pub(crate) fn sync_state_label(state: &str) -> &str {
match state {
"syncing" => "syncing…",
"error" => "sync error",
"conflict" => "needs attention",
_ => "up to date",
}
}
/// Human label for a sync pass in flight: what it is doing, to which file, and
/// how far along it is. Neither phase's total is exact — the scan's is an estimate
/// from the last pass, and the applying total grows as deeper paths are classified
/// — so both read "12 of 40" rather than a percentage that could go backwards.
pub(crate) fn sync_progress_label(p: &SyncProgress) -> String {
match p.phase {
// Before the first pass finishes there is no estimate, so the count would
// be "checked 12 of 12" — worse than saying nothing.
SyncPhase::Scanning if p.total == 0 => "checking for changes…".to_string(),
SyncPhase::Scanning => format!(
"checking for changes — {} of {}",
p.done,
p.total.max(p.done)
),
SyncPhase::Applying => {
let count = format!("{} of {}", p.done + 1, p.total.max(p.done + 1));
if p.current.is_empty() {
format!("syncing {count}")
} else {
format!("syncing {} — {count}", p.current)
}
}
}
}
/// Pick a local folder and hand it to the daemon to sync to this device.
pub(crate) fn prompt_add_sync_folder(ui: &Rc<Ui>) {
let win = ui_window(ui);
let dialog = gtk4::FileDialog::builder()
.title("Add Folder to Sync")
.build();
let ui = ui.clone();
dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| {
let Ok(folder) = res else { return };
let Some(local_path) = folder.path().and_then(|p| p.to_str().map(str::to_string)) else {
return;
};
let rx = spawn_request(
ui.dirs.control_socket(),
Request::AddSyncFolder {
local_path: local_path.clone(),
},
);
let ui = ui.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => {
// The daemon acks before the row exists — registering the
// device and creating the remote folder is off-socket network
// work. A fixed delay here either fires too early (empty list)
// or too late (row flashes in). Instead let the periodic
// `refresh_sync_folders` tick pick the row up whenever it
// actually lands, which is what keeps a running pass live too.
toast(&ui, "Syncing folder…");
}
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't add folder", &message, kind)
}
_ => toast_error(
&ui,
"Couldn't add folder",
"The mount service didn't respond.",
),
}
});
});
}
/// Flip a synced folder between `mirror` and `ondemand`. Reloads after so the
/// row's subtitle and switch reflect the daemon's real state (the request may be
/// rejected, e.g. switching to on-demand while a folder is mid-sync).
pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &str) {
let rx = spawn_request(
ui.dirs.control_socket(),
Request::SetSyncFolderMode {
id,
mode: mode.to_string(),
},
);
let ui = ui.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::Ok { message })) => toast(&ui, &message),
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't change mode", &message, kind)
}
_ => toast_error(
&ui,
"Couldn't change mode",
"The mount service didn't respond.",
),
}
if ui.stack.visible_child_name().as_deref() == Some("devices") {
load_devices(&ui);
}
});
}
/// Confirm, then stop syncing a folder. Offers to also delete the cloud copy.
pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondemand: bool) {
let win = ui_window(ui);
// The two modes leave the user in opposite places, so they can't share a
// sentence. A mirror folder's files are already on this disk and stay there.
// An on-demand folder's are not: the path is a mount over content that lives
// in Proton Drive, so unmounting it leaves an empty directory — which is a
// nasty surprise if the dialog claimed the local files were safe, and is
// recoverable only by turning On-demand off *first* and letting it download.
let body = if ondemand {
format!(
"Stop syncing “{path}”?\n\nThis folder is on-demand: its files live in Proton \
Drive, not on this disk, so the folder will be empty once it is unmounted. To \
keep a local copy, cancel, turn off On-demand, and wait for the download to \
finish before removing it."
)
} else {
format!(
"Stop syncing “{path}”?\n\nThe local files stay on this disk and simply stop \
being synced. Choose whether to also delete the copy in Proton Drive."
)
};
let dialog = adw::AlertDialog::builder()
.heading("Stop syncing folder")
.body(body)
.build();
let group = adw::PreferencesGroup::new();
let delete_remote = adw::SwitchRow::builder()
.title("Also delete from Proton Drive")
.subtitle(if ondemand {
"Deletes the only copy of these files."
} else {
"The local copy is unaffected."
})
.active(false)
.build();
group.add(&delete_remote);
dialog.set_extra_child(Some(&group));
dialog.add_response("cancel", "Cancel");
dialog.add_response("remove", "Stop Syncing");
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
dialog.connect_response(None, move |_, resp| {
if resp == "remove" {
run_devices_mutation(
&ui,
Request::RemoveSyncFolder {
id,
delete_remote: delete_remote.is_active(),
},
"Stopped syncing folder",
"Couldn't stop syncing the folder",
);
}
});
dialog.present(win.as_ref());
}
/// Prompt for a new device name and rename it.
pub(crate) fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading("Rename device")
.body(format!("Rename “{current}”."))
.build();
let group = adw::PreferencesGroup::new();
let row = adw::EntryRow::builder()
.title("New name")
.activates_default(true)
.build();
row.set_text(current);
group.add(&row);
dialog.set_extra_child(Some(&group));
dialog.add_response("cancel", "Cancel");
dialog.add_response("rename", "Rename");
dialog.set_response_appearance("rename", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("rename"));
dialog.set_close_response("cancel");
let ui = ui.clone();
let uid = uid.to_string();
dialog.connect_response(None, move |_, resp| {
if resp != "rename" {
return;
}
let name = row.text().trim().to_string();
if name.is_empty() {
toast_error(&ui, "Couldn't rename device", "A name is required.");
return;
}
run_devices_mutation(
&ui,
Request::RenameDevice {
uid: uid.clone(),
name,
},
"Device renamed",
"Couldn't rename the device",
);
});
dialog.present(win.as_ref());
}
/// Confirm, then remove (deregister) a device.
pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading("Remove computer")
.body(format!(
"Remove “{name}” from this account?\n\nEverything it backed up to Proton Drive is \
deleted along with it. The files on that computer itself are not touched — but \
this cannot be undone from here."
))
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("remove", "Remove");
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
let uid = uid.to_string();
dialog.connect_response(None, move |_, resp| {
if resp == "remove" {
run_devices_mutation(
&ui,
Request::DeleteDevice { uid: uid.clone() },
"Device removed",
"Couldn't remove the device",
);
}
});
dialog.present(win.as_ref());
}
/// Run a mutation raised from the Devices page and reload the page on success.
pub(crate) fn run_devices_mutation(ui: &Rc<Ui>, req: Request, done: &'static str, failed: &'static str) {
ui.busy_begin();
let rx = spawn_request(ui.dirs.control_socket(), req);
let ui = ui.clone();
let done = done.to_string();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
match result {
Ok(Ok(Response::Ok { .. })) => {
load_devices(&ui);
toast(&ui, &done);
}
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
_ => toast_error(&ui, failed, "The mount service didn't respond."),
}
});
}
+237
View File
@@ -0,0 +1,237 @@
use crate::*;
pub(crate) struct LoginState {
// Login page.
pub(crate) email: adw::EntryRow,
pub(crate) password: adw::PasswordEntryRow,
pub(crate) login_button: gtk4::Button,
pub(crate) login_status: gtk4::Label,
}
/// The login page: an email row, password row, a primary "Sign in" button and a
/// status label, centred in a clamp. The 2FA code is prompted lazily in a
/// dialog (see [`prompt_2fa`]) only when the account actually requires it.
#[allow(clippy::type_complexity)]
pub(crate) fn build_login_page() -> (
gtk4::Widget,
(
adw::EntryRow,
adw::PasswordEntryRow,
gtk4::Button,
gtk4::Label,
),
) {
let group = adw::PreferencesGroup::builder()
.title("Sign in to Proton")
.description("Use your Proton account to connect Drive.")
.build();
let email = adw::EntryRow::builder().title("Email or username").build();
let password = adw::PasswordEntryRow::builder().title("Password").build();
group.add(&email);
group.add(&password);
let login_button = gtk4::Button::builder()
.label("Sign in")
.halign(gtk4::Align::Center)
.build();
login_button.add_css_class("suggested-action");
login_button.add_css_class("pill");
let login_status = gtk4::Label::builder()
.wrap(true)
.justify(gtk4::Justification::Center)
.build();
login_status.add_css_class("dim-label");
let header = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
let logo = gtk4::Image::from_icon_name("folder-remote-symbolic");
logo.set_pixel_size(64);
logo.add_css_class("brand-icon");
let title = gtk4::Label::new(Some("Proton Drive"));
title.add_css_class("brand-title");
header.append(&logo);
header.append(&title);
header.set_margin_bottom(12);
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 16);
inner.set_margin_top(32);
inner.set_margin_bottom(32);
inner.set_margin_start(12);
inner.set_margin_end(12);
inner.append(&header);
inner.append(&group);
inner.append(&login_button);
inner.append(&login_status);
let clamp = adw::Clamp::builder()
.maximum_size(420)
.child(&inner)
.build();
let scroll = gtk4::ScrolledWindow::builder().child(&clamp).build();
(
scroll.upcast(),
(email, password, login_button, login_status),
)
}
/// Connect the sign-in button: read the fields, run [`auth::login`] on a worker
/// thread, and report the outcome back on the main loop.
pub(crate) fn wire_login(ui: &Rc<Ui>) {
// Pressing Enter in either field submits the form, so signing in never needs
// a reach for the mouse. Both fields route to the same button.
let btn = ui.login.login_button.clone();
ui.login.email
.connect_entry_activated(move |_| btn.emit_clicked());
let btn = ui.login.login_button.clone();
ui.login.password
.connect_entry_activated(move |_| btn.emit_clicked());
let ui = ui.clone();
let button = ui.login.login_button.clone();
button.connect_clicked(move |_| {
let username = ui.login.email.text().to_string();
let password = ui.login.password.text().to_string();
if username.is_empty() || password.is_empty() {
ui.login.login_status.set_text("Enter your email and password.");
return;
}
ui.login.login_button.set_sensitive(false);
ui.login.login_status.set_text("Signing in…");
let (rx, totp_req_rx) = spawn_login(username, password);
// Surface the 2FA dialog only if the SDK actually asks for a code (i.e.
// the account has 2FA enabled). The worker blocks until the dialog feeds
// back a code (or is cancelled, dropping the sender).
let ui_2fa = ui.clone();
glib::spawn_future_local(async move {
if let Ok(code_tx) = totp_req_rx.recv().await {
prompt_2fa(&ui_2fa, code_tx);
}
});
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx
.recv()
.await
.unwrap_or_else(|_| Err("login cancelled".into()));
ui.login.login_button.set_sensitive(true);
match result {
Ok(()) => {
ui.login.login_status.set_text("");
ui.login.password.set_text("");
// Cache the new identity so `refresh` never hits the keyring.
*ui.session.borrow_mut() = auth::load().ok();
// Enable+start the mount service now that we have a session.
service::enable_start();
refresh(&ui);
}
Err(e) => ui.login.login_status.set_text(&format!("Sign-in failed: {e}")),
}
});
});
}
/// Run the async SRP + optional 2FA login on a dedicated current-thread Tokio
/// runtime. Returns two channels: the first yields the final login result once;
/// the second fires *only if* the account needs a 2FA code, carrying a
/// [`std::sync::mpsc::Sender`] the UI uses to feed the code back. The login
/// closure blocks the worker on that sender until the dialog answers, so the
/// code is requested lazily and can't expire before the password proof.
#[allow(clippy::type_complexity)]
pub(crate) fn spawn_login(
username: String,
password: String,
) -> (
async_channel::Receiver<Result<(), String>>,
async_channel::Receiver<std::sync::mpsc::Sender<String>>,
) {
let (tx, rx) = async_channel::bounded(1);
let (totp_req_tx, totp_req_rx) = async_channel::bounded(1);
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
let _ = tx.send_blocking(Err(e.to_string()));
return;
}
};
let result = rt.block_on(async move {
auth::login(&username, &password, || {
// 2FA required: hand a one-shot sender to the UI and block until
// the dialog supplies the code. A dropped sender (cancelled
// dialog) surfaces as a cancelled login.
let (code_tx, code_rx) = std::sync::mpsc::channel::<String>();
totp_req_tx
.send_blocking(code_tx)
.map_err(|_| pdfs_core::Error::Other("two-factor prompt closed".into()))?;
code_rx
.recv()
.map_err(|_| pdfs_core::Error::Other("two-factor entry cancelled".into()))
})
.await
.map_err(|e| e.to_string())
});
let _ = tx.send_blocking(result);
});
(rx, totp_req_rx)
}
/// Show the lazy two-factor dialog and feed the entered code back to the waiting
/// login worker via `code_tx`. Cancelling (or closing) drops the sender, which
/// the worker reads as a cancelled login.
pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>) {
let dialog = adw::AlertDialog::builder()
.heading("Two-factor authentication")
.body("Enter the code from your authenticator app.")
.build();
let group = adw::PreferencesGroup::new();
let entry = adw::EntryRow::builder()
.title("Authentication code")
.activates_default(true)
.build();
group.add(&entry);
dialog.set_extra_child(Some(&group));
dialog.add_response("cancel", "Cancel");
dialog.add_response("confirm", "Confirm");
dialog.set_response_appearance("confirm", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("confirm"));
dialog.set_close_response("cancel");
let code_tx = RefCell::new(Some(code_tx));
dialog.connect_response(None, move |_, resp| {
// On cancel/close we take + drop `tx` without sending, so the worker's
// recv errors out and the login is reported as cancelled.
if let Some(tx) = code_tx.borrow_mut().take()
&& resp == "confirm"
{
let _ = tx.send(entry.text().trim().to_string());
}
});
let parent = ui.login.login_button.root().and_downcast::<gtk4::Window>();
dialog.present(parent.as_ref());
}
/// Connect the sign-out button: disable+stop the mount service (so the daemon
/// isn't left running without credentials), forget the stored session, and drop
/// back to the login page.
pub(crate) fn wire_logout(ui: &Rc<Ui>, button: &gtk4::Button) {
let ui = ui.clone();
button.connect_clicked(move |_| {
service::disable_stop();
if let Err(e) = auth::logout() {
tracing::error!("logout failed: {e}");
}
*ui.session.borrow_mut() = None;
refresh(&ui);
});
}
+10
View File
@@ -0,0 +1,10 @@
pub(crate) mod activity;
pub(crate) mod browser;
pub(crate) mod devices;
pub(crate) mod login;
pub(crate) mod photo_viewer;
pub(crate) mod photos;
pub(crate) mod shared;
pub(crate) mod shared_by_me;
pub(crate) mod status;
pub(crate) mod trash;
@@ -0,0 +1,694 @@
use crate::*;
/// The lightbox's mutable parts, shared by [`load_photo`], [`navigate_photo`] and
/// the button/key handlers so each one takes a single handle instead of a dozen
/// widget arguments.
pub(crate) struct Viewer {
pub(crate) picture: gtk4::Picture,
pub(crate) spinner: gtk4::Spinner,
pub(crate) status: gtk4::Label,
pub(crate) title: gtk4::Label,
pub(crate) counter: gtk4::Label,
pub(crate) prev: gtk4::Button,
pub(crate) next: gtk4::Button,
/// Details drawer: the toggle that reveals it, the rows it fills in, and the
/// "Show on map" button (hidden when the photo carries no GPS tags).
pub(crate) info_toggle: gtk4::ToggleButton,
pub(crate) info_revealer: gtk4::Revealer,
pub(crate) info_rows: gtk4::Box,
pub(crate) info_map: gtk4::Button,
/// Coordinates behind `info_map`, once a photo with GPS tags is shown.
pub(crate) coords: RefCell<Option<(f64, f64)>>,
/// uid of the photo currently on screen.
pub(crate) uid: RefCell<String>,
/// On-disk path of the full-size photo, once it has been downloaded.
pub(crate) path: RefCell<Option<String>>,
/// True while the full-size photo is still downloading and what's on screen is
/// the upscaled thumbnail — a click-outside-to-close hit test has to size the
/// image from the *thumbnail's* ratio in that window, but more importantly a
/// late reply for a photo the user has already navigated away from must not
/// overwrite the new one. Guarded by comparing against `uid`.
pub(crate) loading: Cell<bool>,
}
/// Camera/exposure/location facts pulled from a photo's own EXIF tags, as
/// label/value pairs for the details drawer.
pub(crate) struct ExifInfo {
/// `("Camera", "Apple iPhone 15")` and friends; empty when the file has no
/// EXIF at all, which is normal for screenshots and re-encoded images.
pub(crate) fields: Vec<(&'static str, String)>,
/// Decimal degrees, if the photo is geotagged.
pub(crate) coords: Option<(f64, f64)>,
}
/// Show the photo behind `uid`: paint its (already cached) thumbnail immediately
/// so the lightbox never opens on a blank screen, ask the daemon for the
/// full-size file, and swap it in — plus its EXIF — when it lands.
pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
viewer.spinner.set_visible(true);
viewer.spinner.start();
viewer.status.set_visible(false);
viewer.loading.set(true);
*viewer.path.borrow_mut() = None;
clear_info(viewer);
// The thumbnail the gallery already decoded stands in for the full photo
// while it downloads: blurry for a moment beats black for a second.
match ui.gallery.photo_tex.borrow().get(&uid) {
Some(texture) => viewer.picture.set_paintable(Some(texture)),
None => viewer.picture.set_paintable(gtk4::gdk::Paintable::NONE),
}
let rx = spawn_request(
ui.dirs.control_socket(),
Request::OpenPhoto { uid: uid.clone() },
);
let viewer = viewer.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
// The user may have moved on while this was in flight; that photo's own
// request owns the viewer now.
if *viewer.uid.borrow() != uid {
return;
}
viewer.spinner.stop();
viewer.spinner.set_visible(false);
viewer.loading.set(false);
let fail = |message: &str| {
viewer.status.set_label(message);
viewer.status.set_visible(true);
};
match result {
Ok(Ok(Response::FilePath { path })) => match gtk4::gdk::Texture::from_filename(&path) {
Ok(texture) => {
viewer.picture.set_paintable(Some(&texture));
*viewer.path.borrow_mut() = Some(path.clone());
show_info(&viewer, &path, read_exif(&path));
}
Err(e) => {
tracing::error!("Failed to load texture for {path}: {e}");
fail("Couldn't render this photo.");
}
},
Ok(Ok(Response::Error { message, .. })) => fail(&message),
Ok(Ok(_)) => fail("Unexpected reply from the mount service."),
Ok(Err(_)) | Err(_) => fail("Couldn't reach Proton Drive."),
}
});
}
/// Warm the cache with the photo `delta` steps away, so stepping there is
/// instant. Fire-and-forget: the daemon serves control connections concurrently,
/// so this rides alongside whatever the user does next, and a failure here is
/// simply a photo that downloads on arrival like it used to.
pub(crate) fn prefetch_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, delta: i32) {
let model = &ui.gallery.model;
let current = find_photo_index(model, &viewer.uid.borrow()).unwrap_or(0) as i32;
let index = current + delta;
if index < 0 || index >= model.n_items() as i32 {
return;
}
let Some(photo) = model
.item(index as u32)
.and_downcast::<BoxedAnyObject>()
.map(|boxed| boxed.borrow::<PhotoItem>().clone())
else {
return;
};
// Never prefetch a video: the lightbox skips over it, and warming it would
// mean downloading a whole clip the user won't watch from here.
if photo.kind == PhotoKind::Video {
return;
}
let rx = spawn_request(
ui.dirs.control_socket(),
Request::OpenPhoto { uid: photo.uid },
);
glib::spawn_future_local(async move {
let _ = rx.recv().await;
});
}
/// Reset the details drawer while the next photo is in flight, so it never shows
/// the previous photo's camera against the new image.
pub(crate) fn clear_info(viewer: &Rc<Viewer>) {
*viewer.coords.borrow_mut() = None;
viewer.info_map.set_visible(false);
while let Some(row) = viewer.info_rows.first_child() {
viewer.info_rows.remove(&row);
}
let label = gtk4::Label::builder()
.label("Reading photo details…")
.halign(gtk4::Align::Start)
.build();
label.add_css_class("dim-label");
viewer.info_rows.append(&label);
}
/// Fill the details drawer for the photo now on screen: its own file facts
/// (size, dimensions) plus whatever EXIF it carries.
pub(crate) fn show_info(viewer: &Rc<Viewer>, path: &str, info: ExifInfo) {
while let Some(row) = viewer.info_rows.first_child() {
viewer.info_rows.remove(&row);
}
let mut fields: Vec<(&str, String)> = Vec::new();
if let Ok(meta) = std::fs::metadata(path) {
fields.push(("Size", human_bytes(meta.len())));
}
fields.extend(info.fields.iter().map(|(k, v)| (*k, v.clone())));
if fields.is_empty() {
let label = gtk4::Label::builder()
.label("This photo carries no metadata.")
.halign(gtk4::Align::Start)
.wrap(true)
.build();
label.add_css_class("dim-label");
viewer.info_rows.append(&label);
} else {
let group = gtk4::ListBox::new();
group.set_selection_mode(gtk4::SelectionMode::None);
group.add_css_class("boxed-list");
for (label, value) in fields {
let row = adw::ActionRow::builder()
.title(label)
.subtitle(value)
.subtitle_selectable(true)
.build();
row.add_css_class("property");
group.append(&row);
}
viewer.info_rows.append(&group);
}
viewer.info_map.set_visible(info.coords.is_some());
*viewer.coords.borrow_mut() = info.coords;
}
/// Read the EXIF tags a gallery viewer cares about out of a decrypted photo on
/// disk. Anything missing is simply left out — phone screenshots and re-encoded
/// images legitimately carry no EXIF at all.
pub(crate) fn read_exif(path: &str) -> ExifInfo {
let mut fields: Vec<(&'static str, String)> = Vec::new();
let mut coords = None;
let reader = match std::fs::File::open(path) {
Ok(file) => {
match exif::Reader::new().read_from_container(&mut std::io::BufReader::new(file)) {
Ok(reader) => reader,
Err(e) => {
tracing::debug!("no exif in {path}: {e}");
return ExifInfo { fields, coords };
}
}
}
Err(e) => {
tracing::warn!("cannot open {path} for exif: {e}");
return ExifInfo { fields, coords };
}
};
let field = |tag: exif::Tag| {
reader
.get_field(tag, exif::In::PRIMARY)
.map(|f| f.display_value().with_unit(&reader).to_string())
};
if let Some(size) = field(exif::Tag::PixelXDimension)
.zip(field(exif::Tag::PixelYDimension))
.map(|(w, h)| format!("{w} × {h}"))
{
fields.push(("Dimensions", size));
}
if let Some(taken) = field(exif::Tag::DateTimeOriginal) {
fields.push(("Taken", taken));
}
let camera = [exif::Tag::Make, exif::Tag::Model]
.iter()
.filter_map(|tag| field(*tag))
.collect::<Vec<_>>()
.join(" ");
if !camera.is_empty() {
fields.push(("Camera", camera));
}
if let Some(lens) = field(exif::Tag::LensModel) {
fields.push(("Lens", lens));
}
let exposure: Vec<String> = [
exif::Tag::FNumber,
exif::Tag::ExposureTime,
exif::Tag::PhotographicSensitivity,
exif::Tag::FocalLength,
]
.iter()
.filter_map(|tag| field(*tag))
.collect();
if !exposure.is_empty() {
fields.push(("Exposure", exposure.join(" · ")));
}
if let Some(lat) = gps_degrees(&reader, exif::Tag::GPSLatitude, exif::Tag::GPSLatitudeRef)
&& let Some(lon) = gps_degrees(&reader, exif::Tag::GPSLongitude, exif::Tag::GPSLongitudeRef)
{
fields.push(("Location", format!("{lat:.5}, {lon:.5}")));
coords = Some((lat, lon));
}
ExifInfo { fields, coords }
}
/// Convert one GPS coordinate from EXIF's degrees/minutes/seconds rationals to
/// decimal degrees, negating for the S/W hemispheres.
pub(crate) fn gps_degrees(reader: &exif::Exif, tag: exif::Tag, ref_tag: exif::Tag) -> Option<f64> {
let field = reader.get_field(tag, exif::In::PRIMARY)?;
let exif::Value::Rational(dms) = &field.value else {
return None;
};
let [deg, min, sec] = dms.get(..3)? else {
return None;
};
let degrees = deg.to_f64() + min.to_f64() / 60.0 + sec.to_f64() / 3600.0;
let hemisphere = reader
.get_field(ref_tag, exif::In::PRIMARY)
.map(|f| f.display_value().to_string())
.unwrap_or_default();
let negative = hemisphere.starts_with('S') || hemisphere.starts_with('W');
Some(if negative { -degrees } else { degrees })
}
pub(crate) fn save_photo_to_disk(window: &gtk4::Window, source_path: &str, original_name: &str) {
let dialog = gtk4::FileDialog::builder()
.title("Save Photo")
.initial_name(original_name)
.build();
let source_path_str = source_path.to_string();
dialog.save(Some(window), gio::Cancellable::NONE, move |res| {
if let Ok(file) = res
&& let Some(dest_path) = file.path()
{
if let Err(e) = std::fs::copy(&source_path_str, &dest_path) {
tracing::error!("Failed to copy file to {:?}: {}", dest_path, e);
} else {
tracing::info!("Saved photo to {:?}", dest_path);
}
}
});
}
/// Step `delta` photos through the flat timeline model and load what lands there.
pub(crate) fn navigate_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, delta: i32) {
let model = &ui.gallery.model;
let n = model.n_items();
if n == 0 {
return;
}
let uid_val = viewer.uid.borrow().clone();
let current_idx = find_photo_index(model, &uid_val).unwrap_or(0);
// Where the raw step lands (prev/next are ±1; Home/End are huge deltas that
// clamp to the ends), and which way it was travelling.
let start = (current_idx as i32 + delta).clamp(0, n as i32 - 1);
let dir = if delta < 0 { -1 } else { 1 };
// The lightbox renders stills only — a video plays in an external player, so
// it has no frame here. Land on the nearest renderable item, preferring the
// travel direction and falling back to scanning inward from a boundary (so
// Home/End still reach the first/last still even when videos sit at the end).
let renderable = |idx: u32| -> Option<PhotoItem> {
model
.item(idx)
.and_then(|obj| obj.downcast_ref::<BoxedAnyObject>().map(|b| b.borrow::<PhotoItem>().clone()))
.filter(|photo| photo.kind != PhotoKind::Video)
};
let scan = |from: i32, step: i32| -> Option<(u32, PhotoItem)> {
let mut probe = from;
while (0..n as i32).contains(&probe) {
if let Some(photo) = renderable(probe as u32) {
return Some((probe as u32, photo));
}
probe += step;
}
None
};
// Scan the travel direction first. Only fall back to scanning inward when the
// step landed on a boundary — that is the Home/End case (jump to first/last,
// then step inward past any videos there); a mid-list prev/next with only
// videos ahead should simply stay put rather than reverse.
let at_boundary = start == 0 || start == n as i32 - 1;
let landing = scan(start, dir).or_else(|| at_boundary.then(|| scan(start, -dir)).flatten());
let Some((next_idx, photo)) = landing else {
return;
};
*viewer.uid.borrow_mut() = photo.uid.clone();
show_photo_position(viewer, next_idx, n, photo.capture_time);
load_photo(ui, viewer, photo.uid.clone());
// Keep walking in the same direction: the next one is likely where they're
// headed, so have it in the cache before they ask.
prefetch_photo(ui, viewer, delta.signum());
}
/// Set the lightbox's title (the photo's date) and its "12 of 340" counter.
pub(crate) fn show_photo_position(viewer: &Rc<Viewer>, index: u32, total: u32, capture_time: i64) {
viewer.prev.set_sensitive(index > 0);
viewer.next.set_sensitive(index + 1 < total);
viewer.title.set_label(&format_capture_time(capture_time));
viewer
.counter
.set_label(&format!("{} of {total}", index + 1));
}
/// The in-app lightbox: the photo, edge-to-edge on a dark backdrop, with a
/// floating top bar, prev/next affordances and a details drawer.
///
/// Closing it is deliberately hard to get wrong — Escape, `q`, Ctrl+W, the close
/// button, or a click on the backdrop beside the photo all dismiss it.
pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let parent = ui.stack.root().and_downcast::<gtk4::Window>().unwrap();
let window = gtk4::Window::builder()
.title("Photo")
.modal(true)
.transient_for(&parent)
.default_width(1100)
.default_height(760)
.build();
window.add_css_class("photo-viewer-window");
let overlay = gtk4::Overlay::new();
let picture = gtk4::Picture::builder()
.content_fit(gtk4::ContentFit::Contain)
.hexpand(true)
.vexpand(true)
.build();
overlay.set_child(Some(&picture));
let prev_btn = gtk4::Button::builder()
.icon_name("go-previous-symbolic")
.tooltip_text("Previous (←)")
.halign(gtk4::Align::Start)
.valign(gtk4::Align::Center)
.build();
prev_btn.add_css_class("circular");
prev_btn.add_css_class("flat");
prev_btn.add_css_class("viewer-nav-btn");
overlay.add_overlay(&prev_btn);
let next_btn = gtk4::Button::builder()
.icon_name("go-next-symbolic")
.tooltip_text("Next (→)")
.halign(gtk4::Align::End)
.valign(gtk4::Align::Center)
.build();
next_btn.add_css_class("circular");
next_btn.add_css_class("flat");
next_btn.add_css_class("viewer-nav-btn");
overlay.add_overlay(&next_btn);
// Top bar: the photo's date and position on the left, actions on the right,
// over a gradient so white controls stay legible on a bright photo.
let title_label = gtk4::Label::builder()
.halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
title_label.add_css_class("viewer-title");
let counter_label = gtk4::Label::builder().halign(gtk4::Align::Start).build();
counter_label.add_css_class("viewer-counter");
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 1);
titles.set_hexpand(true);
titles.set_valign(gtk4::Align::Center);
titles.append(&title_label);
titles.append(&counter_label);
let action = |icon: &str, tooltip: &str| {
let button = gtk4::Button::builder()
.icon_name(icon)
.tooltip_text(tooltip)
.valign(gtk4::Align::Center)
.build();
button.add_css_class("flat");
button.add_css_class("viewer-action-btn");
button
};
let info_toggle = gtk4::ToggleButton::builder()
.icon_name("info-outline-symbolic")
.tooltip_text("Details (i)")
.valign(gtk4::Align::Center)
.build();
info_toggle.add_css_class("flat");
info_toggle.add_css_class("viewer-action-btn");
let download_btn = action("document-save-symbolic", "Save a copy…");
let open_ext_btn = action("document-open-symbolic", "Open with another app");
let close_btn = action("window-close-symbolic", "Close (Esc)");
close_btn.add_css_class("viewer-close-btn");
let top_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
top_bar.add_css_class("viewer-top-bar");
top_bar.set_valign(gtk4::Align::Start);
top_bar.set_hexpand(true);
top_bar.append(&titles);
top_bar.append(&info_toggle);
top_bar.append(&download_btn);
top_bar.append(&open_ext_btn);
top_bar.append(&close_btn);
overlay.add_overlay(&top_bar);
// Details drawer: slides in from the right as a real surface (not a wash of
// black over the photo), with its own header and its own way out.
let info_rows = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
let info_map = gtk4::Button::builder()
.label("Show on map")
.icon_name("map-symbolic")
.halign(gtk4::Align::Start)
.build();
info_map.add_css_class("pill");
info_map.set_visible(false);
let info_title = gtk4::Label::builder()
.label("Details")
.halign(gtk4::Align::Start)
.hexpand(true)
.build();
info_title.add_css_class("heading");
let info_close = gtk4::Button::builder()
.icon_name("window-close-symbolic")
.tooltip_text("Hide details")
.build();
info_close.add_css_class("flat");
info_close.add_css_class("circular");
let info_header = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
info_header.append(&info_title);
info_header.append(&info_close);
let info_body = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
info_body.set_margin_top(16);
info_body.set_margin_bottom(16);
info_body.set_margin_start(16);
info_body.set_margin_end(16);
info_body.append(&info_header);
info_body.append(&info_rows);
info_body.append(&info_map);
// Scrolled, because a photo with a full EXIF block plus a location can
// outgrow a short window.
let info_scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never)
.propagate_natural_height(true)
.child(&info_body)
.build();
let info_panel = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
info_panel.add_css_class("viewer-info-panel");
info_panel.set_width_request(320);
info_panel.append(&info_scroll);
let info_revealer = gtk4::Revealer::builder()
.transition_type(gtk4::RevealerTransitionType::SlideLeft)
.halign(gtk4::Align::End)
.valign(gtk4::Align::Fill)
.child(&info_panel)
.build();
overlay.add_overlay(&info_revealer);
let spinner = gtk4::Spinner::builder()
.halign(gtk4::Align::Center)
.valign(gtk4::Align::Center)
.width_request(48)
.height_request(48)
.build();
spinner.add_css_class("viewer-spinner");
overlay.add_overlay(&spinner);
let status_label = gtk4::Label::builder()
.wrap(true)
.justify(gtk4::Justification::Center)
.halign(gtk4::Align::Center)
.valign(gtk4::Align::Center)
.build();
status_label.add_css_class("viewer-status");
overlay.add_overlay(&status_label);
window.set_child(Some(&overlay));
let viewer = Rc::new(Viewer {
picture: picture.clone(),
spinner,
status: status_label,
title: title_label,
counter: counter_label,
prev: prev_btn.clone(),
next: next_btn.clone(),
info_toggle: info_toggle.clone(),
info_revealer: info_revealer.clone(),
info_rows,
info_map: info_map.clone(),
coords: RefCell::new(None),
uid: RefCell::new(initial_uid.clone()),
path: RefCell::new(None),
loading: Cell::new(false),
});
let n = ui.gallery.model.n_items();
let initial_idx = find_photo_index(&ui.gallery.model, &initial_uid).unwrap_or(0);
let capture_time = ui
.gallery.model
.item(initial_idx)
.and_downcast::<BoxedAnyObject>()
.map_or(0, |boxed| boxed.borrow::<PhotoItem>().capture_time);
show_photo_position(&viewer, initial_idx, n, capture_time);
load_photo(ui, &viewer, initial_uid);
prefetch_photo(ui, &viewer, 1);
let w_close = window.clone();
close_btn.connect_clicked(move |_| {
w_close.close();
});
let viewer_info = viewer.clone();
info_toggle.connect_toggled(move |toggle| {
viewer_info
.info_revealer
.set_reveal_child(toggle.is_active());
});
let toggle_off = info_toggle.clone();
info_close.connect_clicked(move |_| toggle_off.set_active(false));
let viewer_map = viewer.clone();
info_map.connect_clicked(move |_| {
if let Some((lat, lon)) = *viewer_map.coords.borrow() {
open_path(&format!(
"https://www.openstreetmap.org/?mlat={lat:.6}&mlon={lon:.6}#map=16/{lat:.6}/{lon:.6}"
));
}
});
let w_download = window.clone();
let viewer_download = viewer.clone();
download_btn.connect_clicked(move |_| {
if let Some(path) = viewer_download.path.borrow().as_deref() {
let name = format!("{}.jpg", viewer_download.uid.borrow());
save_photo_to_disk(&w_download, path, &name);
}
});
let viewer_ext = viewer.clone();
open_ext_btn.connect_clicked(move |_| {
if let Some(path) = viewer_ext.path.borrow().as_deref() {
open_path(path);
}
});
let ui_prev = ui.clone();
let viewer_prev = viewer.clone();
prev_btn.connect_clicked(move |_| {
navigate_photo(&ui_prev, &viewer_prev, -1);
});
let ui_next = ui.clone();
let viewer_next = viewer.clone();
next_btn.connect_clicked(move |_| {
navigate_photo(&ui_next, &viewer_next, 1);
});
// Click the backdrop — the dark area beside the photo — to dismiss, the way
// every other lightbox behaves. Clicks on the photo itself are left alone, so
// reaching for the image doesn't fling the window shut.
let backdrop = gtk4::GestureClick::new();
let viewer_click = viewer.clone();
let w_click = window.clone();
backdrop.connect_released(move |_, _, x, y| {
if !over_photo(&viewer_click.picture, x, y) {
w_click.close();
}
});
picture.add_controller(backdrop);
let key_controller = gtk4::EventControllerKey::new();
let ui_key = ui.clone();
let viewer_key = viewer.clone();
let w_key = window.clone();
key_controller.connect_key_pressed(move |_, key, _keycode, state| {
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
match key.name().as_deref() {
Some("Left" | "Up" | "BackSpace") => navigate_photo(&ui_key, &viewer_key, -1),
Some("Right" | "Down" | "space") => navigate_photo(&ui_key, &viewer_key, 1),
Some("Home") => navigate_photo(&ui_key, &viewer_key, i32::MIN / 2),
Some("End") => navigate_photo(&ui_key, &viewer_key, i32::MAX / 2),
Some("i") => viewer_key
.info_toggle
.set_active(!viewer_key.info_toggle.is_active()),
Some("f" | "F11") => {
if w_key.is_fullscreen() {
w_key.unfullscreen();
} else {
w_key.fullscreen();
}
}
Some("Escape" | "q") => w_key.close(),
Some("w") if ctrl => w_key.close(),
_ => return glib::Propagation::Proceed,
}
glib::Propagation::Stop
});
window.add_controller(key_controller);
window.present();
}
/// Whether `(x, y)` — in `picture`'s coordinates — lands on the photo itself
/// rather than the backdrop around it. [`gtk4::ContentFit::Contain`] centres the
/// image and letterboxes the rest, so the drawn rectangle is the widget scaled
/// down by whichever axis binds.
pub(crate) fn over_photo(picture: &gtk4::Picture, x: f64, y: f64) -> bool {
let (width, height) = (f64::from(picture.width()), f64::from(picture.height()));
let Some(paintable) = picture.paintable() else {
return false;
};
let (iw, ih) = (
f64::from(paintable.intrinsic_width()),
f64::from(paintable.intrinsic_height()),
);
if iw <= 0.0 || ih <= 0.0 {
return false;
}
let scale = (width / iw).min(height / ih);
let (drawn_w, drawn_h) = (iw * scale, ih * scale);
let (left, top) = ((width - drawn_w) / 2.0, (height - drawn_h) / 2.0);
x >= left && x <= left + drawn_w && y >= top && y <= top + drawn_h
}
File diff suppressed because it is too large. Load diff
+492
View File
@@ -0,0 +1,492 @@
use crate::*;
pub(crate) struct SharedState {
// Shared page. Three sections rebuilt wholesale on each load: shared-with-me,
// incoming invitations, and bookmarks. Rows are tracked so a reload can remove
// the old ones before appending the new (adw groups have no clear-all).
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) retry: gtk4::Button,
pub(crate) with_me_group: adw::PreferencesGroup,
pub(crate) invitations_group: adw::PreferencesGroup,
pub(crate) bookmarks_group: adw::PreferencesGroup,
pub(crate) rows: RefCell<Vec<(adw::PreferencesGroup, gtk4::Widget)>>,
/// Guards the Shared page's load so overlapping navigations don't stack.
pub(crate) inflight: Cell<bool>,
/// When the Shared page last painted good data. `None` = never / invalidated,
/// forcing a fetch on next visit. See [`PAGE_TTL`].
pub(crate) loaded_at: Cell<Option<Instant>>,
}
/// Widgets the Shared page's load/repaint touch.
pub(crate) struct SharedWidgets {
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) shared_with_me: adw::PreferencesGroup,
pub(crate) invitations: adw::PreferencesGroup,
pub(crate) bookmarks: adw::PreferencesGroup,
pub(crate) retry: gtk4::Button,
pub(crate) add_bookmark: gtk4::Button,
pub(crate) refresh: gtk4::Button,
}
/// The Shared page: three stacked sections — items shared *with* me (each with a
/// Leave action), invitations addressed to me pending accept/reject, and public
/// links I've bookmarked (open / remove). All three live outside the mount tree,
/// so the page addresses them by uid/id/token and always re-lists from the daemon.
pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
let title = gtk4::Label::builder()
.label("Shared with me")
.halign(gtk4::Align::Start)
.build();
title.add_css_class("title-2");
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
titles.set_hexpand(true);
titles.append(&title);
let add_bookmark = gtk4::Button::builder()
.label("Add Bookmark")
.valign(gtk4::Align::Center)
.build();
add_bookmark.add_css_class("flat");
let refresh = refresh_button();
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header.append(&titles);
header.append(&refresh);
header.append(&add_bookmark);
let shared_with_me = adw::PreferencesGroup::builder()
.title("Shared with me")
.build();
let invitations = adw::PreferencesGroup::builder()
.title("Invitations")
.build();
let bookmarks = adw::PreferencesGroup::builder().title("Bookmarks").build();
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
groups.append(&shared_with_me);
groups.append(&invitations);
groups.append(&bookmarks);
let clamp = adw::Clamp::builder().child(&groups).build();
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.child(&clamp)
.build();
let retry = gtk4::Button::builder()
.label("Retry")
.halign(gtk4::Align::Center)
.build();
retry.add_css_class("pill");
retry.add_css_class("suggested-action");
retry.set_visible(false);
let status = adw::StatusPage::builder()
.icon_name("emblem-shared-symbolic")
.vexpand(true)
.child(&retry)
.build();
status.add_css_class("compact");
let content = gtk4::Stack::new();
content.set_vexpand(true);
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
content.add_named(&scroll, Some("list"));
content.add_named(&status, Some("status"));
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12);
inner.set_margin_bottom(12);
inner.set_margin_start(12);
inner.set_margin_end(12);
inner.append(&header);
inner.append(&content);
(
inner.upcast(),
SharedWidgets {
content,
status,
shared_with_me,
invitations,
bookmarks,
retry,
add_bookmark,
refresh,
},
)
}
/// Install the Shared page's retry and Add-Bookmark buttons.
pub(crate) fn wire_shared(ui: &Rc<Ui>, retry: &gtk4::Button, add_bookmark: &gtk4::Button) {
let ui_retry = ui.clone();
retry.connect_clicked(move |_| {
service::restart();
load_shared(&ui_retry);
});
let ui_add = ui.clone();
add_bookmark.connect_clicked(move |_| prompt_add_bookmark(&ui_add));
}
/// Show a status page in place of the Shared sections.
pub(crate) fn shared_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
ui.shared.status.set_icon_name(Some(icon));
ui.shared.status.set_title(title);
ui.shared.status.set_description(Some(description));
ui.shared.retry.set_visible(retry);
ui.shared.content.set_visible_child_name("status");
}
/// Fetch the three Shared sections (shared-with-me, invitations, bookmarks) in
/// parallel and repaint the page once all three land.
pub(crate) fn load_shared(ui: &Rc<Ui>) {
if ui.shared.inflight.get() {
return;
}
ui.shared.inflight.set(true);
shared_status(
ui,
"emblem-shared-symbolic",
"Loading…",
"Reading your shared items.",
false,
);
ui.busy_begin();
let socket = ui.dirs.control_socket();
let shared_rx = spawn_request(socket.clone(), Request::ListSharedWithMe);
let invites_rx = spawn_request(socket.clone(), Request::ListInvitations);
let bookmarks_rx = spawn_request(socket, Request::ListBookmarks);
let ui = ui.clone();
glib::spawn_future_local(async move {
let shared = shared_rx.recv().await;
let invites = invites_rx.recv().await;
let bookmarks = bookmarks_rx.recv().await;
ui.busy_end();
ui.shared.inflight.set(false);
// A transport failure on any of the three means the daemon isn't up.
if matches!(shared, Ok(Err(_)) | Err(_))
|| matches!(invites, Ok(Err(_)) | Err(_))
|| matches!(bookmarks, Ok(Err(_)) | Err(_))
{
ui.shared.loaded_at.set(None);
shared_unreachable(&ui);
return;
}
let shared_items = match shared {
Ok(Ok(Response::Entries { entries })) => entries,
_ => Vec::new(),
};
let invitations = match invites {
Ok(Ok(Response::Invitations { items })) => items,
_ => Vec::new(),
};
let bookmark_items = match bookmarks {
Ok(Ok(Response::Bookmarks { items })) => items,
_ => Vec::new(),
};
repaint_shared(&ui, &shared_items, &invitations, &bookmark_items);
ui.shared.loaded_at.set(Some(Instant::now()));
});
}
/// The daemon didn't answer the Shared page. Same still-starting vs. down split
/// as the other pages.
pub(crate) fn shared_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() {
shared_status(
ui,
"network-offline-symbolic",
"Not connected",
"The Proton Drive mount service isn't running.",
true,
);
return;
}
shared_status(
ui,
"folder-remote-symbolic",
"Connecting…",
"Waiting for the Proton Drive mount service to come up.",
false,
);
let ui = ui.clone();
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
if ui.stack.visible_child_name().as_deref() == Some("shared") {
load_shared(&ui);
}
});
}
/// Rebuild the three Shared sections. Rows added last time are removed first —
/// adw groups have no clear-all — then each section is repopulated, showing a
/// dim placeholder row when empty.
pub(crate) fn repaint_shared(
ui: &Rc<Ui>,
shared: &[DirEntry],
invitations: &[InvitationInfo],
bookmarks: &[BookmarkInfo],
) {
for (group, row) in ui.shared.rows.borrow_mut().drain(..) {
group.remove(&row);
}
ui.shared.content.set_visible_child_name("list");
let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new();
// Shared with me: name + Leave.
if shared.is_empty() {
let row = dim_row("Nothing is shared with you.");
ui.shared.with_me_group.add(&row);
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
} else {
for entry in shared {
let row = adw::ActionRow::builder().title(&entry.name).build();
row.add_prefix(&gtk4::Image::from_icon_name(if entry.is_dir {
"folder-symbolic"
} else {
"text-x-generic-symbolic"
}));
let leave = gtk4::Button::builder()
.label("Leave")
.valign(gtk4::Align::Center)
.build();
leave.add_css_class("flat");
let ui_leave = ui.clone();
let uid = entry.uid.clone();
let name = entry.name.clone();
leave.connect_clicked(move |_| prompt_leave_shared(&ui_leave, &uid, &name));
row.add_suffix(&leave);
ui.shared.with_me_group.add(&row);
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
}
}
// Invitations: inviter + item, Accept / Reject.
if invitations.is_empty() {
let row = dim_row("No pending invitations.");
ui.shared.invitations_group.add(&row);
rows.push((ui.shared.invitations_group.clone(), row.upcast()));
} else {
for inv in invitations {
let item = inv
.name
.clone()
.unwrap_or_else(|| "a shared item".to_string());
let row = adw::ActionRow::builder()
.title(&item)
.subtitle(format!("from {}", inv.inviter_email))
.build();
row.add_prefix(&gtk4::Image::from_icon_name(if inv.is_dir {
"folder-symbolic"
} else {
"text-x-generic-symbolic"
}));
let reject = gtk4::Button::builder()
.icon_name("window-close-symbolic")
.tooltip_text("Reject")
.valign(gtk4::Align::Center)
.build();
reject.add_css_class("flat");
let accept = gtk4::Button::builder()
.label("Accept")
.valign(gtk4::Align::Center)
.build();
accept.add_css_class("suggested-action");
let ui_acc = ui.clone();
let id_acc = inv.id.clone();
accept.connect_clicked(move |_| {
respond_invitation(&ui_acc, &id_acc, true);
});
let ui_rej = ui.clone();
let id_rej = inv.id.clone();
reject.connect_clicked(move |_| {
respond_invitation(&ui_rej, &id_rej, false);
});
row.add_suffix(&accept);
row.add_suffix(&reject);
ui.shared.invitations_group.add(&row);
rows.push((ui.shared.invitations_group.clone(), row.upcast()));
}
}
// Bookmarks: name/URL, Open / Remove.
if bookmarks.is_empty() {
let row = dim_row("No saved bookmarks.");
ui.shared.bookmarks_group.add(&row);
rows.push((ui.shared.bookmarks_group.clone(), row.upcast()));
} else {
for bm in bookmarks {
let title = bm.name.clone().unwrap_or_else(|| "Shared link".to_string());
let row = adw::ActionRow::builder()
.title(&title)
.subtitle(&bm.url)
.build();
row.add_prefix(&gtk4::Image::from_icon_name("emblem-symbolic-link"));
let remove = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Remove bookmark")
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
let open = gtk4::Button::builder()
.icon_name("external-link-symbolic")
.tooltip_text("Open in browser")
.valign(gtk4::Align::Center)
.build();
open.add_css_class("flat");
let url_open = bm.url.clone();
open.connect_clicked(move |_| open_uri(&url_open));
let ui_rm = ui.clone();
let token = bm.token.clone();
let name_rm = title.clone();
remove.connect_clicked(move |_| prompt_remove_bookmark(&ui_rm, &token, &name_rm));
row.add_suffix(&open);
row.add_suffix(&remove);
ui.shared.bookmarks_group.add(&row);
rows.push((ui.shared.bookmarks_group.clone(), row.upcast()));
}
}
*ui.shared.rows.borrow_mut() = rows;
}
/// Accept or reject an invitation, then reload the Shared page.
pub(crate) fn respond_invitation(ui: &Rc<Ui>, id: &str, accept: bool) {
let req = if accept {
Request::AcceptInvitation { id: id.to_string() }
} else {
Request::RejectInvitation { id: id.to_string() }
};
let (done, failed) = if accept {
("Invitation accepted", "Couldn't accept the invitation")
} else {
("Invitation rejected", "Couldn't reject the invitation")
};
run_shared_mutation(ui, req, done, failed);
}
/// Confirm, then leave a node shared with me.
pub(crate) fn prompt_leave_shared(ui: &Rc<Ui>, uid: &str, name: &str) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading("Leave shared item")
.body(format!(
"Leave “{name}”? You'll lose access until you're invited again."
))
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("leave", "Leave");
dialog.set_response_appearance("leave", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
let uid = uid.to_string();
dialog.connect_response(None, move |_, resp| {
if resp == "leave" {
run_shared_mutation(
&ui,
Request::LeaveShared { uid: uid.clone() },
"Left shared item",
"Couldn't leave the shared item",
);
}
});
dialog.present(win.as_ref());
}
/// Confirm, then remove a saved bookmark.
pub(crate) fn prompt_remove_bookmark(ui: &Rc<Ui>, token: &str, name: &str) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading("Remove bookmark")
.body(format!("Remove the bookmark for “{name}”?"))
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("remove", "Remove");
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
let token = token.to_string();
dialog.connect_response(None, move |_, resp| {
if resp == "remove" {
run_shared_mutation(
&ui,
Request::DeleteBookmark {
token: token.clone(),
},
"Bookmark removed",
"Couldn't remove the bookmark",
);
}
});
dialog.present(win.as_ref());
}
/// Prompt for a public-link URL (and optional password) and save it as a bookmark.
pub(crate) fn prompt_add_bookmark(ui: &Rc<Ui>) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading("Add bookmark")
.body("Paste a Proton Drive public link to save it here.")
.build();
let group = adw::PreferencesGroup::new();
let url_row = adw::EntryRow::builder()
.title("Public link URL")
.activates_default(true)
.build();
let pw_row = adw::PasswordEntryRow::builder()
.title("Password (if the link has one)")
.activates_default(true)
.build();
group.add(&url_row);
group.add(&pw_row);
dialog.set_extra_child(Some(&group));
dialog.add_response("cancel", "Cancel");
dialog.add_response("add", "Add");
dialog.set_response_appearance("add", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("add"));
dialog.set_close_response("cancel");
let ui = ui.clone();
dialog.connect_response(None, move |_, resp| {
if resp != "add" {
return;
}
let url = url_row.text().trim().to_string();
if url.is_empty() {
toast_error(&ui, "Couldn't add bookmark", "A URL is required.");
return;
}
let pw = pw_row.text().to_string();
let password = if pw.is_empty() { None } else { Some(pw) };
run_shared_mutation(
&ui,
Request::CreateBookmark { url, password },
"Bookmark saved",
"Couldn't add the bookmark",
);
});
dialog.present(win.as_ref());
}
/// Run a mutation raised from the Shared page and reload the page on success.
pub(crate) fn run_shared_mutation(ui: &Rc<Ui>, req: Request, done: &'static str, failed: &'static str) {
ui.busy_begin();
let rx = spawn_request(ui.dirs.control_socket(), req);
let ui = ui.clone();
let done = done.to_string();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
match result {
Ok(Ok(Response::Ok { .. })) => {
load_shared(&ui);
toast(&ui, &done);
}
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
_ => toast_error(&ui, failed, "The mount service didn't respond."),
}
});
}
@@ -0,0 +1,293 @@
use crate::*;
pub(crate) struct SharedByMeState {
// Shared (by me) page: one section listing the items I've shared, each with a
// copy-link / manage affordance. Rebuilt wholesale on each load.
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) retry: gtk4::Button,
pub(crate) group: adw::PreferencesGroup,
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
pub(crate) inflight: Cell<bool>,
pub(crate) loaded_at: Cell<Option<Instant>>,
}
/// Widgets the Shared (by-me) page's load/repaint touch.
pub(crate) struct SharedByMeWidgets {
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) group: adw::PreferencesGroup,
pub(crate) retry: gtk4::Button,
pub(crate) refresh: gtk4::Button,
}
/// The Shared page: one section listing the items I have shared with others —
/// each row summarizing who has access and carrying its public link (copy/open)
/// and a Manage shortcut into the per-node Share dialog.
pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
let title = gtk4::Label::builder()
.label("Shared")
.halign(gtk4::Align::Start)
.hexpand(true)
.build();
title.add_css_class("title-2");
let refresh = refresh_button();
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header.set_hexpand(true);
header.append(&title);
header.append(&refresh);
let group = adw::PreferencesGroup::new();
let clamp = adw::Clamp::builder().child(&group).build();
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.child(&clamp)
.build();
let retry = gtk4::Button::builder()
.label("Retry")
.halign(gtk4::Align::Center)
.build();
retry.add_css_class("pill");
retry.add_css_class("suggested-action");
retry.set_visible(false);
let status = adw::StatusPage::builder()
.icon_name("emblem-shared-symbolic")
.vexpand(true)
.child(&retry)
.build();
status.add_css_class("compact");
let content = gtk4::Stack::new();
content.set_vexpand(true);
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
content.add_named(&scroll, Some("list"));
content.add_named(&status, Some("status"));
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12);
inner.set_margin_bottom(12);
inner.set_margin_start(12);
inner.set_margin_end(12);
inner.append(&header);
inner.append(&content);
(
inner.upcast(),
SharedByMeWidgets {
content,
status,
group,
retry,
refresh,
},
)
}
/// Install the Shared (by-me) page's retry button.
pub(crate) fn wire_shared_by_me(ui: &Rc<Ui>, retry: &gtk4::Button) {
let ui_retry = ui.clone();
retry.connect_clicked(move |_| {
service::restart();
load_shared_by_me(&ui_retry);
});
}
/// Show a status page in place of the Shared (by-me) list.
pub(crate) fn shared_by_me_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
ui.shared_by_me.status.set_icon_name(Some(icon));
ui.shared_by_me.status.set_title(title);
ui.shared_by_me.status.set_description(Some(description));
ui.shared_by_me.retry.set_visible(retry);
ui.shared_by_me.content.set_visible_child_name("status");
}
/// Fetch the shared-by-me listing and repaint the page.
pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
if ui.shared_by_me.inflight.get() {
return;
}
ui.shared_by_me.inflight.set(true);
shared_by_me_status(
ui,
"emblem-shared-symbolic",
"Loading…",
"Reading what you've shared.",
false,
);
ui.busy_begin();
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSharedByMe);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
ui.shared_by_me.inflight.set(false);
match result {
Ok(Ok(Response::SharedByMe { items })) => {
repaint_shared_by_me(&ui, &items);
ui.shared_by_me.loaded_at.set(Some(Instant::now()));
}
Ok(Ok(Response::Error { message, .. })) => shared_by_me_status(
&ui,
"dialog-warning-symbolic",
"Couldn't read your shares",
&message,
false,
),
Ok(Ok(_)) => shared_by_me_status(
&ui,
"dialog-warning-symbolic",
"Couldn't read your shares",
"Unexpected reply from the mount service.",
false,
),
Ok(Err(_)) | Err(_) => {
ui.shared_by_me.loaded_at.set(None);
shared_by_me_unreachable(&ui);
}
}
});
}
/// The daemon didn't answer the Shared (by-me) page.
pub(crate) fn shared_by_me_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() {
shared_by_me_status(
ui,
"network-offline-symbolic",
"Not connected",
"The Proton Drive mount service isn't running.",
true,
);
return;
}
shared_by_me_status(
ui,
"folder-remote-symbolic",
"Connecting…",
"Waiting for the Proton Drive mount service to come up.",
false,
);
let ui = ui.clone();
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
if ui.stack.visible_child_name().as_deref() == Some("sharedbyme") {
load_shared_by_me(&ui);
}
});
}
/// Rebuild the Shared (by-me) section from a fresh listing.
pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
for row in ui.shared_by_me.rows.borrow_mut().drain(..) {
ui.shared_by_me.group.remove(&row);
}
if items.is_empty() {
shared_by_me_status(
ui,
"emblem-shared-symbolic",
"Nothing shared yet",
"Items you share with people or by link show up here.",
false,
);
return;
}
ui.shared_by_me.content.set_visible_child_name("list");
let mut rows: Vec<gtk4::Widget> = Vec::new();
for item in items {
let row = adw::ActionRow::builder()
.title(&item.name)
.subtitle(shared_item_summary(item))
.build();
row.add_prefix(&gtk4::Image::from_icon_name(if item.is_dir {
"folder-symbolic"
} else {
"text-x-generic-symbolic"
}));
// Copy / open the public link, when there is one with a recovered URL.
if let Some(url) = item.link.as_ref().and_then(|l| l.url.clone()) {
let copy = gtk4::Button::builder()
.icon_name("edit-copy-symbolic")
.tooltip_text("Copy link")
.valign(gtk4::Align::Center)
.build();
copy.add_css_class("flat");
let ui_copy = ui.clone();
let url_copy = url.clone();
copy.connect_clicked(move |btn| {
btn.clipboard().set_text(&url_copy);
toast(&ui_copy, "Link copied");
});
let open = gtk4::Button::builder()
.icon_name("external-link-symbolic")
.tooltip_text("Open link")
.valign(gtk4::Align::Center)
.build();
open.add_css_class("flat");
let url_open = url.clone();
open.connect_clicked(move |_| open_uri(&url_open));
row.add_suffix(&copy);
row.add_suffix(&open);
}
// Manage opens the per-node Share dialog — only when the node's path is
// known (it has been browsed to this session); the dialog is path-keyed.
if !item.path.is_empty() {
let manage = gtk4::Button::builder()
.label("Manage")
.valign(gtk4::Align::Center)
.build();
manage.add_css_class("flat");
let ui_manage = ui.clone();
let entry = shared_item_as_entry(item);
manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry));
row.add_suffix(&manage);
}
ui.shared_by_me.group.add(&row);
rows.push(row.upcast());
}
*ui.shared_by_me.rows.borrow_mut() = rows;
}
/// A one-line summary of who can reach a shared item, for its row subtitle.
pub(crate) fn shared_item_summary(item: &SharedItem) -> String {
let mut parts = Vec::new();
if item.member_count > 0 {
parts.push(format!(
"{} {}",
item.member_count,
if item.member_count == 1 {
"person"
} else {
"people"
}
));
}
if item.invite_count > 0 {
parts.push(format!("{} pending", item.invite_count));
}
if item.link.is_some() {
parts.push("Public link".to_string());
}
if parts.is_empty() {
"Shared".to_string()
} else {
parts.join(" · ")
}
}
/// Build a [`DirEntry`] from a [`SharedItem`] so the path-keyed Share dialog can
/// open on it. Only used when the item's path is known.
pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
DirEntry {
name: item.name.clone(),
is_dir: item.is_dir,
size: 0,
modified: 0,
pinned: false,
cached: false,
uid: item.uid.clone(),
path: item.path.clone(),
}
}
+787
View File
@@ -0,0 +1,787 @@
use crate::*;
pub(crate) struct StatusState {
/// Whether a [`Request::Status`] round-trip is already in flight, so the 2s
/// refresh tick doesn't pile worker threads up on a slow/wedged daemon.
pub(crate) status_inflight: Cell<bool>,
/// Guards the [`Request::GetQueueStatus`] poll the same way: at most one
/// in-flight at a time so a wedged daemon can't stack worker threads.
pub(crate) transfers_inflight: Cell<bool>,
/// Activity group + its current rows, hidden when no transfer is in flight.
pub(crate) transfers_group: adw::PreferencesGroup,
pub(crate) transfer_rows: RefCell<Vec<TransferRow>>,
// Main page.
pub(crate) account_row: adw::ActionRow,
/// Read-only mount status line. The mount is driven by the systemd user
/// service (enabled on login), not by the user — so this only reports.
pub(crate) mount_row: adw::ActionRow,
pub(crate) cache_bar: gtk4::ProgressBar,
pub(crate) cache_label: gtk4::Label,
/// "Start on login" toggle. [`Self::settings_suppress`] guards programmatic
/// sets so reflecting the systemd state doesn't fire the toggle handler.
pub(crate) autostart_row: adw::SwitchRow,
/// Cache-budget editor (GiB). Populated once from config; user edits drive a
/// `SetCacheBudget` round-trip. Guarded by [`Self::settings_suppress`].
pub(crate) budget_row: adw::SpinRow,
/// Shows the active mountpoint in its subtitle; updated when the user picks a
/// new folder.
pub(crate) mountpoint_row: adw::ActionRow,
/// Set while a settings widget is being populated programmatically, so its
/// change handler skips the IPC/systemd side effect.
pub(crate) settings_suppress: Cell<bool>,
pub(crate) pins_group: adw::PreferencesGroup,
/// Rows currently shown under [`Self::pins_group`], retained so a refresh can
/// diff against them and only rebuild when the pin set actually changes.
pub(crate) pin_rows: RefCell<Vec<PinRow>>,
/// The pin paths last rendered, the diff baseline for [`repaint_pins`].
/// `None` = nothing built yet; `Some(empty)` = the placeholder is shown.
pub(crate) pins_state: RefCell<Option<Vec<String>>>,
/// The mount state the *last* desktop notification reported, so a flap only
/// notifies on the edge. `None` until the first status reply, so a cold start
/// doesn't announce "disconnected" before the service has had a chance to come
/// up.
pub(crate) notified_mounted: Cell<Option<bool>>,
/// How many transfers were in flight on the previous poll. A drop to zero is
/// what "sync complete" means; there's no completion event on the wire.
pub(crate) active_transfers: Cell<usize>,
}
/// One rendered pin row, retained so [`repaint_pins`] can flip the unpin button's
/// `sensitive` in place (when the pin set is unchanged) instead of rebuilding.
pub(crate) struct PinRow {
pub(crate) row: adw::ActionRow,
/// The unpin button, absent on the placeholder row.
pub(crate) unpin: Option<gtk4::Button>,
}
/// One rendered row in the Activity group: a description over a progress bar.
/// Retained so [`repaint_transfers`] can update the bar and label in place each
/// tick when the active set is unchanged, instead of rebuilding.
pub(crate) struct TransferRow {
pub(crate) row: adw::PreferencesRow,
pub(crate) label: gtk4::Label,
pub(crate) bar: gtk4::ProgressBar,
}
/// What one Activity row should say this tick, and how far along it is —
/// `None` meaning "no total known", which the bar shows by pulsing. Jobs and
/// transfers both render to this, so the group is one list in the order the
/// daemon reports: the jobs that frame the work, then the files moving under it.
pub(crate) struct ActivityLine {
pub(crate) text: String,
pub(crate) fraction: Option<f64>,
}
/// Widgets the settings page hands back for the refresh loop and action wiring.
pub(crate) struct MainWidgets {
pub(crate) account_row: adw::ActionRow,
pub(crate) mount_row: adw::ActionRow,
/// Live upload/download progress, populated by the refresh loop.
pub(crate) transfers_group: adw::PreferencesGroup,
pub(crate) cache_bar: gtk4::ProgressBar,
pub(crate) cache_label: gtk4::Label,
pub(crate) pins_group: adw::PreferencesGroup,
pub(crate) logout_button: gtk4::Button,
/// "Start on login" toggle, reflecting the systemd unit's enabled state.
pub(crate) autostart_row: adw::SwitchRow,
/// Cache soft-cap editor, in GiB; `0` = unlimited.
pub(crate) budget_row: adw::SpinRow,
/// Purges all unpinned cached content.
pub(crate) purge_button: gtk4::Button,
/// Shows the active mountpoint; its suffix button opens a folder chooser.
pub(crate) mountpoint_row: adw::ActionRow,
pub(crate) mountpoint_button: gtk4::Button,
}
/// The main (logged-in) page: a libadwaita settings surface — account header,
/// mount status, storage controls (cache budget + purge), system integration
/// (start-on-login, mountpoint), the pin list, and developer overrides. Returns
/// the widgets the refresh loop updates plus the controls to wire.
pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
// Account group: identity + sign-out.
let account_group = adw::PreferencesGroup::new();
let account_row = adw::ActionRow::builder().title("Not signed in").build();
let avatar = adw::Avatar::new(40, None, true);
account_row.add_prefix(&avatar);
let logout_button = gtk4::Button::builder()
.label("Sign out")
.valign(gtk4::Align::Center)
.build();
logout_button.add_css_class("flat");
account_row.add_suffix(&logout_button);
account_group.add(&account_row);
// Mount group: a read-only status line. The mount is managed automatically
// by the systemd user service; there is no toggle to fiddle with.
let mount_group = adw::PreferencesGroup::builder().title("Drive").build();
let mount_row = adw::ActionRow::builder()
.title("Proton Drive")
.subtitle("Not mounted")
.build();
mount_group.add(&mount_row);
// Activity group: live upload/download progress. Hidden until the refresh
// loop sees an in-flight transfer from `Request::GetQueueStatus`.
let transfers_group = adw::PreferencesGroup::builder()
.title("Activity")
.description("Files moving to and from Proton Drive.")
.visible(false)
.build();
// Storage group: a progress bar + "X of Y used" label, plus the cache-budget
// editor and a purge button. `budget_row`/`purge_button` are wired in
// `wire_settings`; the bar + label are repainted by the refresh loop.
let storage_group = adw::PreferencesGroup::builder()
.title("Storage")
.description("Local cache for pinned and recently opened files.")
.build();
let storage_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
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();
cache_label.add_css_class("dim-label");
storage_box.append(&cache_bar);
storage_box.append(&cache_label);
let usage_row = adw::PreferencesRow::builder()
.activatable(false)
.child(&storage_box)
.build();
storage_group.add(&usage_row);
// Cache budget, expressed in GiB. 0 = unlimited; the daemon applies a 0 cap
// as "no eviction". Step in 0.5 GiB; the upper bound is generous.
let budget_adj = gtk4::Adjustment::new(0.0, 0.0, 1024.0, 0.5, 1.0, 0.0);
let budget_row = adw::SpinRow::builder()
.title("Cache budget (GiB)")
.subtitle("Soft cap for cached content; 0 = unlimited.")
.adjustment(&budget_adj)
.digits(1)
.build();
storage_group.add(&budget_row);
let purge_row = adw::ActionRow::builder()
.title("Purge cache")
.subtitle("Delete cached content. Pinned files are kept.")
.build();
let purge_button = gtk4::Button::builder()
.label("Purge")
.valign(gtk4::Align::Center)
.build();
purge_button.add_css_class("destructive-action");
purge_row.add_suffix(&purge_button);
storage_group.add(&purge_row);
// System integration: start-on-login + mountpoint chooser.
let system_group = adw::PreferencesGroup::builder()
.title("System integration")
.build();
let autostart_row = adw::SwitchRow::builder()
.title("Start on login")
.subtitle("Mount Proton Drive automatically when you log in.")
.build();
system_group.add(&autostart_row);
let mountpoint_row = adw::ActionRow::builder()
.title("Mountpoint")
.subtitle("—")
.build();
let mountpoint_button = gtk4::Button::builder()
.label("Change")
.valign(gtk4::Align::Center)
.build();
mountpoint_button.add_css_class("flat");
mountpoint_row.add_suffix(&mountpoint_button);
system_group.add(&mountpoint_row);
// Pins group: filled in by refresh.
let pins_group = adw::PreferencesGroup::builder()
.title("Pinned files")
.description("Kept available offline on this device.")
.build();
// Developer overrides: read-only client identity, for support/debugging.
let dev_group = adw::PreferencesGroup::builder().title("Developer").build();
let version_row = adw::ActionRow::builder()
.title("App version")
.subtitle(pdfs_core::config::APP_VERSION)
.build();
version_row.add_css_class("property");
let agent_row = adw::ActionRow::builder()
.title("User agent")
.subtitle(pdfs_core::config::USER_AGENT)
.build();
agent_row.add_css_class("property");
dev_group.add(&version_row);
dev_group.add(&agent_row);
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
inner.set_margin_top(18);
inner.set_margin_bottom(18);
inner.set_margin_start(12);
inner.set_margin_end(12);
inner.append(&account_group);
inner.append(&mount_group);
inner.append(&transfers_group);
inner.append(&storage_group);
inner.append(&system_group);
inner.append(&pins_group);
inner.append(&dev_group);
let clamp = adw::Clamp::builder()
.maximum_size(560)
.child(&inner)
.build();
let scroll = gtk4::ScrolledWindow::builder().child(&clamp).build();
(
scroll.upcast(),
MainWidgets {
account_row,
mount_row,
transfers_group,
cache_bar,
cache_label,
pins_group,
logout_button,
autostart_row,
budget_row,
purge_button,
mountpoint_row,
mountpoint_button,
},
)
}
/// Bytes per GiB, for the cache-budget editor's unit conversion.
pub(crate) const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
/// Wire the Settings-page controls: the cache-budget editor, the purge button,
/// the start-on-login switch and the mountpoint chooser. Initial widget state is
/// read once from config / systemd here (the refresh loop owns only the live
/// mount + cache-usage read-out), with [`Ui::settings_suppress`] set around the
/// programmatic populate so the change handlers don't fire on it.
pub(crate) fn wire_settings(ui: &Rc<Ui>, purge_button: &gtk4::Button, mountpoint_button: &gtk4::Button) {
let config = ui.dirs.load_config();
// Populate from persisted config + the systemd unit state, suppressed.
ui.status.settings_suppress.set(true);
ui.status.budget_row
.set_value(config.resolved_cache_budget() as f64 / GIB);
ui.status.mountpoint_row
.set_subtitle(&ui.dirs.resolved_mountpoint(&config).display().to_string());
ui.status.autostart_row.set_active(service::is_enabled());
ui.status.settings_suppress.set(false);
// Cache budget: a user edit applies the new soft cap on the daemon (which
// also persists it to config). 0 GiB = unlimited.
let ui_budget = ui.clone();
ui.status.budget_row.connect_value_notify(move |row| {
if ui_budget.status.settings_suppress.get() {
return;
}
let bytes = (row.value() * GIB).round() as u64;
settings_request(
&ui_budget,
Request::SetCacheBudget { bytes },
"Cache budget updated",
"Couldn't set cache budget",
);
});
// Purge: confirm, then drop all unpinned cached content via the daemon.
let ui_purge = ui.clone();
purge_button.connect_clicked(move |_| {
let ui = ui_purge.clone();
let dialog = adw::AlertDialog::builder()
.heading("Purge cache")
.body("Delete all cached content that isn't pinned? Pinned files stay offline.")
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("purge", "Purge");
dialog.set_response_appearance("purge", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
dialog.connect_response(None, move |_, resp| {
if resp == "purge" {
settings_request(
&ui,
Request::PurgeCache,
"Cache purged",
"Couldn't purge cache",
);
}
});
dialog.present(ui_window(&ui_purge).as_ref());
});
// Start on login: enable/disable the systemd unit without stopping a live
// mount (the user can disconnect separately).
let ui_auto = ui.clone();
ui.status.autostart_row.connect_active_notify(move |row| {
if ui_auto.status.settings_suppress.get() {
return;
}
if row.is_active() {
service::enable();
} else {
service::disable();
}
});
// Mountpoint: pick a folder, persist it, and offer to restart the mount so
// the change takes effect (the daemon reads the path on mount).
let ui_mp = ui.clone();
mountpoint_button.connect_clicked(move |_| prompt_mountpoint(&ui_mp));
}
/// Run a settings control-socket round-trip (budget / purge) on a worker thread,
/// confirming with `done` or reporting the daemon's error under `failed`. Unlike
/// [`run_mutation`] there's no browser reload; the next refresh tick repaints the
/// cache read-out.
pub(crate) fn settings_request(ui: &Rc<Ui>, req: Request, done: &'static str, failed: &'static str) {
ui.busy_begin();
let rx = spawn_request(ui.dirs.control_socket(), req);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
match result {
Ok(Ok(Response::Ok { .. })) => toast(&ui, done),
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
_ => toast_error(&ui, failed, "The mount service didn't respond."),
}
});
}
/// Prompt for a new mountpoint folder, persist it to config, and offer to restart
/// the mount service so the daemon picks it up.
pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
let win = ui_window(ui);
let dialog = gtk4::FileDialog::builder()
.title("Choose mountpoint folder")
.build();
let ui = ui.clone();
dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| {
let Ok(folder) = res else { return };
let Some(path) = folder.path() else { return };
let path_str = path.display().to_string();
// Persist the choice to config so the next mount uses it.
let mut config = ui.dirs.load_config();
config.mountpoint = Some(path_str.clone());
if let Err(e) = ui.dirs.save_config(&config) {
toast_error(&ui, "Couldn't save mountpoint", &e.to_string());
return;
}
ui.status.mountpoint_row.set_subtitle(&path_str);
// The daemon only reads the mountpoint at mount time, so offer a restart.
let confirm = adw::AlertDialog::builder()
.heading("Restart to apply")
.body(format!(
"The mountpoint is now “{path_str}”. Restart the Drive mount to use it?"
))
.build();
confirm.add_response("later", "Later");
confirm.add_response("restart", "Restart now");
confirm.set_response_appearance("restart", adw::ResponseAppearance::Suggested);
confirm.set_default_response(Some("restart"));
confirm.set_close_response("later");
confirm.connect_response(None, |_, resp| {
if resp == "restart" {
service::restart();
}
});
confirm.present(ui_window(&ui).as_ref());
});
}
/// Connect the Files/Photos "Retry" buttons (shown by [`browser_unreachable`] /
/// [`gallery_unreachable`] when the mount is down): restart the systemd unit and
/// reload the page.
pub(crate) fn wire_retry(ui: &Rc<Ui>) {
let ui_browser = ui.clone();
ui.browser.retry.clone().connect_clicked(move |_| {
service::restart();
load_browser(&ui_browser);
});
let ui_gallery = ui.clone();
ui.gallery.retry.clone().connect_clicked(move |_| {
service::restart();
load_gallery(&ui_gallery, false);
});
let ui_trash = ui.clone();
ui.trash.retry.clone().connect_clicked(move |_| {
service::restart();
load_trash(&ui_trash);
});
}
/// Repaint the window from the cached login identity, then kick an async mount-
/// status fetch. Runs on the 2s tick: the identity check is instant (no keyring),
/// and the status round-trip is offloaded to a worker so the main loop never
/// blocks on a slow or wedged daemon.
pub(crate) fn refresh(ui: &Rc<Ui>) {
// Login identity decides which page is shown. Read the cached session — set
// at startup and on login/logout — never the keyring.
{
let session = ui.session.borrow();
match session.as_ref() {
Some(s) => {
// Only pull the user onto a destination when they're sitting on the
// login page; otherwise leave whichever page they navigated to.
if ui.stack.visible_child_name().as_deref() == Some("login") {
ui.stack.set_visible_child_name("browser");
}
ui.nav.set_collapsed(false);
ui.status.account_row.set_title(&s.username);
ui.status.account_row.set_subtitle("Proton account");
}
None => {
ui.stack.set_visible_child_name("login");
// Collapsed + showing content = the login page owns the window and
// no destination is reachable without a session.
ui.nav.set_collapsed(true);
ui.nav.set_show_content(true);
return;
}
}
}
refresh_status(ui);
refresh_transfers(ui);
// Both of these pages show work as it happens, so they follow the tick while
// they are on screen. Every other page loads on navigation only.
match ui.stack.visible_child_name().as_deref() {
Some("devices") => refresh_sync_folders(ui),
Some("activity") => refresh_activity(ui),
_ => {}
}
}
/// Record the mount state seen by the last status poll: gate every control that
/// needs a live daemon, and notify the desktop when the state actually flips.
///
/// The gating is the point — without it, New Folder / Upload / the details pane's
/// actions stay clickable while the mount is down and each click buys a round-trip
/// that can only fail. A greyed control says so up front.
pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) {
*ui.mounted.borrow_mut() = mounted;
ui.browser.new_folder.set_sensitive(mounted);
ui.browser.upload.set_sensitive(mounted);
ui.browser.upload_folder.set_sensitive(mounted);
ui.gallery.upload.set_sensitive(mounted);
ui.details.details.pin_row.set_sensitive(mounted);
ui.details.details.rename_button.set_sensitive(mounted);
ui.details.details.trash_button.set_sensitive(mounted);
ui.details.details.open_button.set_sensitive(mounted);
// Only notify on a real edge, and never for the first reading: at startup the
// service is usually still coming up, and "disconnected" would be a lie.
if ui.status.notified_mounted.get() == Some(mounted) {
return;
}
let first = ui.status.notified_mounted.replace(Some(mounted)).is_none();
if first {
return;
}
if mounted {
notify(
"mount-state",
"Proton Drive connected",
"Your Drive is mounted and available.",
);
} else {
notify(
"mount-state",
"Proton Drive disconnected",
"The mount service stopped. Files aren't available until it restarts.",
);
}
}
/// Poll the daemon's in-flight transfers on a worker thread and repaint the
/// Activity group. Independently inflight-guarded from [`refresh_status`] so the
/// two cheap polls on the 2s tick don't gate each other. The group hides itself
/// when nothing is moving, so an idle account shows no Activity section.
pub(crate) fn refresh_transfers(ui: &Rc<Ui>) {
if ui.status.transfers_inflight.get() {
return;
}
ui.status.transfers_inflight.set(true);
let rx = spawn_request(ui.dirs.control_socket(), Request::GetQueueStatus);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.status.transfers_inflight.set(false);
match result {
Ok(Ok(Response::Transfers { items, jobs })) => repaint_transfers(&ui, &items, &jobs),
// Daemon unreachable or odd reply: clear the section rather than
// leave stale progress bars frozen on screen.
_ => repaint_transfers(&ui, &[], &[]),
}
});
}
/// Render the Activity group from a work snapshot: the daemon's jobs (scans,
/// batch counts, the local index) above the files moving under them. Rebuilds the
/// rows only when the set changes (count differs); on the common steady tick it
/// updates each bar's fraction and the label in place, so progress animates
/// without flicker. Hides the whole group when the daemon is idle.
pub(crate) fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem], jobs: &[JobItem]) {
let lines: Vec<ActivityLine> = jobs
.iter()
.map(job_line)
.chain(items.iter().map(transfer_line))
.collect();
// The wire carries in-flight transfers only, with no completion event: the
// count falling to zero is what "the sync finished" looks like from here.
// Jobs are deliberately not counted — a bulk upload retires its scan job and
// starts its upload job mid-flight, which is not a thing finishing.
let previous = ui.status.active_transfers.replace(items.len());
if items.is_empty() && previous > 0 {
let files = if previous == 1 {
"1 file".to_string()
} else {
format!("{previous} files")
};
notify(
"sync-complete",
"Sync complete",
&format!("{files} finished transferring."),
);
// A just-finished batch may have added files (bulk upload) the current
// listing doesn't show yet; refresh whichever listing is on screen.
reload_listing(ui);
}
if lines.is_empty() {
if !ui.status.transfer_rows.borrow().is_empty() {
for tr in ui.status.transfer_rows.borrow_mut().drain(..) {
ui.status.transfers_group.remove(&tr.row);
}
}
ui.status.transfers_group.set_visible(false);
return;
}
ui.status.transfers_group.set_visible(true);
// Rebuild rows only when the count changes; otherwise reuse them in place.
if ui.status.transfer_rows.borrow().len() != lines.len() {
for tr in ui.status.transfer_rows.borrow_mut().drain(..) {
ui.status.transfers_group.remove(&tr.row);
}
for _ in &lines {
let row_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
row_box.set_margin_top(8);
row_box.set_margin_bottom(8);
let label = gtk4::Label::builder().halign(gtk4::Align::Start).build();
label.add_css_class("dim-label");
let bar = gtk4::ProgressBar::new();
row_box.append(&label);
row_box.append(&bar);
let row = adw::PreferencesRow::builder()
.activatable(false)
.child(&row_box)
.build();
ui.status.transfers_group.add(&row);
ui.status.transfer_rows
.borrow_mut()
.push(TransferRow { row, label, bar });
}
}
for (line, tr) in lines.iter().zip(ui.status.transfer_rows.borrow().iter()) {
tr.label.set_text(&line.text);
match line.fraction {
Some(f) => tr.bar.set_fraction(f),
// No total to divide by: pulse so the bar still reads as "working".
None => tr.bar.pulse(),
}
}
}
/// One Activity row for a daemon job: its title, plus whatever it can say about
/// where it is — a count when it has one, else what it is currently chewing on.
pub(crate) fn job_line(j: &JobItem) -> ActivityLine {
let text = match (j.total > 0, j.detail.is_empty()) {
(true, true) => format!("{} — {} of {}", j.title, j.done, j.total),
(true, false) => format!("{} — {} ({} of {})", j.title, j.detail, j.done, j.total),
(false, true) => format!("{}…", j.title),
(false, false) => format!("{} — {}…", j.title, j.detail),
};
ActivityLine {
text,
fraction: (j.total > 0).then(|| (j.done as f64 / j.total as f64).min(1.0)),
}
}
/// One Activity row for a file in flight: which way it's going, how far, how fast.
pub(crate) fn transfer_line(t: &TransferItem) -> ActivityLine {
let arrow = match t.direction {
TransferDirection::Download => "↓",
TransferDirection::Upload => "↑",
};
if t.bytes_total == 0 {
ActivityLine {
text: format!(
"{arrow} {} — {} ({}/s)",
t.name,
human_bytes(t.bytes_completed),
human_bytes(t.speed_bytes_sec),
),
fraction: None,
}
} else {
ActivityLine {
text: format!(
"{arrow} {} — {} of {} ({}/s)",
t.name,
human_bytes(t.bytes_completed),
human_bytes(t.bytes_total),
human_bytes(t.speed_bytes_sec),
),
fraction: Some((t.bytes_completed as f64 / t.bytes_total as f64).min(1.0)),
}
}
}
/// Fetch mount status + cache stats from the daemon on a worker thread and repaint
/// the mount line, cache bar and pin list on the reply. The daemon owns the cache
/// stats now (`used`/`budget`/`pins` ride along on [`Response::Status`]), so the
/// GUI never opens the on-disk cache itself. Skipped while a fetch is in flight so
/// the tick can't stack threads on a stalled daemon.
pub(crate) fn refresh_status(ui: &Rc<Ui>) {
if ui.status.status_inflight.get() {
return;
}
ui.status.status_inflight.set(true);
let rx = spawn_request(ui.dirs.control_socket(), Request::Status);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.status.status_inflight.set(false);
match result {
Ok(Ok(Response::Status {
mountpoint,
used,
budget,
pins,
online,
pending_uploads,
pending_changes,
..
})) => {
set_mounted(&ui, true);
// The queue is the more useful thing to say when it has anything
// in it: it is why a file that looks saved is not on the remote
// yet, and offline is usually the reason it is still queued.
let queued = pending_summary(pending_uploads, pending_changes);
ui.status.mount_row.set_subtitle(&match (online, queued) {
(true, None) => format!("Mounted at {mountpoint}"),
(true, Some(q)) => format!("Mounted at {mountpoint} — {q}"),
(false, None) => {
format!("Mounted at {mountpoint} — offline, cached files only")
}
(false, Some(q)) => format!("Mounted at {mountpoint} — offline, {q}"),
});
let fraction = if budget == 0 {
0.0
} else {
(used as f64 / budget as f64).min(1.0)
};
ui.status.cache_bar.set_fraction(fraction);
ui.status.cache_label.set_text(&format!(
"{} of {} used",
human_bytes(used),
human_bytes(budget)
));
repaint_pins(&ui, &pins, true);
}
// Daemon unreachable (still starting, or down): report not-mounted and
// grey out the unpin buttons in place, but leave the last-known pin
// rows and cache read-out so the page doesn't flicker on a blip.
_ => {
set_mounted(&ui, false);
ui.status.mount_row.set_subtitle("Not mounted");
for r in ui.status.pin_rows.borrow().iter() {
if let Some(b) = &r.unpin {
b.set_sensitive(false);
}
}
}
}
});
}
/// Render the pins group from `pins`, with the unpin buttons enabled only while a
/// mount daemon is running (`mounted`). Diffs against the last batch by path: when
/// the set is unchanged (the common case on the 2s tick) it only flips the unpin
/// buttons' `sensitive` flag, avoiding the rebuild that used to flicker the list
/// and drop scroll/selection every tick.
pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted: bool) {
let desired: Vec<String> = pins.iter().map(|p| p.path.clone()).collect();
if ui.status.pins_state.borrow().as_ref() == Some(&desired) {
for r in ui.status.pin_rows.borrow().iter() {
if let Some(b) = &r.unpin {
b.set_sensitive(mounted);
}
}
return;
}
for pr in ui.status.pin_rows.borrow_mut().drain(..) {
ui.status.pins_group.remove(&pr.row);
}
*ui.status.pins_state.borrow_mut() = Some(desired);
if pins.is_empty() {
let row = adw::ActionRow::builder()
.title("No pinned files")
.subtitle("Right-click a file in the mount to keep it offline.")
.build();
ui.status.pins_group.add(&row);
ui.status.pin_rows.borrow_mut().push(PinRow { row, unpin: None });
return;
}
for pin in pins {
let name = Path::new(&pin.path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&pin.path)
.to_string();
let row = adw::ActionRow::builder()
.title(&name)
.subtitle(&pin.path)
.build();
let icon = gtk4::Image::from_icon_name("emblem-documents-symbolic");
row.add_prefix(&icon);
let unpin = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.valign(gtk4::Align::Center)
.tooltip_text("Unpin (remove offline copy)")
.sensitive(mounted)
.build();
unpin.add_css_class("flat");
let ui_btn = ui.clone();
let path = pin.path.clone();
unpin.connect_clicked(move |_| {
let socket = ui_btn.dirs.control_socket();
match send(&socket, &Request::Unpin { path: path.clone() }) {
Ok(Response::Error { message, .. }) => tracing::error!("unpin failed: {message}"),
Ok(_) => refresh(&ui_btn),
Err(e) => tracing::error!("unpin request failed: {e}"),
}
});
row.add_suffix(&unpin);
ui.status.pins_group.add(&row);
ui.status.pin_rows.borrow_mut().push(PinRow {
row,
unpin: Some(unpin),
});
}
}
+398
View File
@@ -0,0 +1,398 @@
use crate::*;
pub(crate) struct TrashState {
// Trash page. Trashed nodes are addressed by uid, not by path, so this page
// keeps no current-directory state — it re-lists from the daemon on show.
pub(crate) model: gio::ListStore,
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) retry: gtk4::Button,
/// Empties the trash; insensitive while it is empty (or unread).
pub(crate) empty: gtk4::Button,
/// "12 items" under the page title.
pub(crate) subtitle: gtk4::Label,
}
/// The widgets of the Trash page that a load repaints.
pub(crate) struct TrashWidgets {
pub(crate) model: gio::ListStore,
pub(crate) list: gtk4::ListView,
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) retry: gtk4::Button,
pub(crate) empty: gtk4::Button,
pub(crate) refresh: gtk4::Button,
pub(crate) subtitle: gtk4::Label,
}
/// The Trash page: a flat list of everything Drive is holding in the trash, each
/// row offering Restore and Delete Forever, with Empty Trash in the header.
///
/// A trashed node has no path inside the mount — the daemon forgets it when it is
/// trashed — so unlike the Files page this one addresses entries by uid and always
/// re-lists from the server rather than from a cached listing. Row rendering needs
/// the [`Ui`] handle for its buttons, so the factory is installed in [`wire_trash`].
pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
let model = gio::ListStore::new::<BoxedAnyObject>();
let title = gtk4::Label::builder()
.label("Trash")
.halign(gtk4::Align::Start)
.build();
title.add_css_class("title-2");
let subtitle = gtk4::Label::builder().halign(gtk4::Align::Start).build();
subtitle.add_css_class("dim-label");
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
titles.set_hexpand(true);
titles.append(&title);
titles.append(&subtitle);
let empty = gtk4::Button::builder()
.label("Empty Trash")
.valign(gtk4::Align::Center)
.sensitive(false)
.build();
empty.add_css_class("destructive-action");
let refresh = refresh_button();
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header.append(&titles);
header.append(&refresh);
header.append(&empty);
let retry = gtk4::Button::builder()
.label("Retry")
.halign(gtk4::Align::Center)
.build();
retry.add_css_class("pill");
retry.add_css_class("suggested-action");
retry.set_visible(false);
let status = adw::StatusPage::builder()
.icon_name("user-trash-symbolic")
.vexpand(true)
.child(&retry)
.build();
status.add_css_class("compact");
// No selection model: every action lives on the row it acts on, so a
// mis-aimed Delete Forever isn't one click away from a stale selection.
let list = gtk4::ListView::builder()
.model(&gtk4::NoSelection::new(Some(model.clone())))
.build();
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.child(&list)
.build();
let content = gtk4::Stack::new();
content.set_vexpand(true);
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
content.add_named(&scroll, Some("list"));
content.add_named(&status, Some("status"));
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12);
inner.set_margin_bottom(12);
inner.set_margin_start(12);
inner.set_margin_end(12);
inner.append(&header);
inner.append(&content);
(
inner.upcast(),
TrashWidgets {
model,
list,
content,
status,
retry,
empty,
refresh,
subtitle,
},
)
}
/// Install the row factory and the Empty Trash button. The row's two buttons read
/// the entry off the [`gtk4::ListItem`] they were clicked on rather than a
/// captured copy, so a recycled row always acts on the item it currently shows.
pub(crate) fn wire_trash(ui: &Rc<Ui>, list: &gtk4::ListView, empty: &gtk4::Button) {
let factory = gtk4::SignalListItemFactory::new();
let ui_setup = ui.clone();
factory.connect_setup(move |_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let icon = gtk4::Image::builder().pixel_size(32).build();
let name = gtk4::Label::builder()
.halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
.build();
let meta = gtk4::Label::builder().halign(gtk4::Align::Start).build();
meta.add_css_class("dim-label");
meta.add_css_class("caption");
let text = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
text.set_hexpand(true);
text.set_valign(gtk4::Align::Center);
text.append(&name);
text.append(&meta);
let restore = gtk4::Button::builder()
.icon_name("edit-undo-symbolic")
.tooltip_text("Restore to its original folder")
.valign(gtk4::Align::Center)
.build();
restore.add_css_class("flat");
let purge = gtk4::Button::builder()
.icon_name("edit-delete-symbolic")
.tooltip_text("Delete permanently")
.valign(gtk4::Align::Center)
.build();
purge.add_css_class("flat");
let ui_restore = ui_setup.clone();
let item_restore = item.clone();
restore.connect_clicked(move |_| {
if let Some(entry) = bound_entry(&item_restore) {
restore_entry(&ui_restore, &entry);
}
});
let ui_purge = ui_setup.clone();
let item_purge = item.clone();
purge.connect_clicked(move |_| {
if let Some(entry) = bound_entry(&item_purge) {
prompt_delete_forever(&ui_purge, &entry);
}
});
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
row.set_margin_top(6);
row.set_margin_bottom(6);
row.set_margin_start(6);
row.set_margin_end(6);
row.append(&icon);
row.append(&text);
row.append(&restore);
row.append(&purge);
item.set_child(Some(&row));
});
factory.connect_bind(|_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let Some(entry) = bound_entry(item) else {
return;
};
let row = item.child().and_downcast::<gtk4::Box>().unwrap();
let Some(icon) = row.first_child().and_downcast::<gtk4::Image>() else {
return;
};
icon.set_icon_name(Some(&format!("{}-symbolic", icon_base_for(&entry))));
let Some(text) = icon.next_sibling().and_downcast::<gtk4::Box>() else {
return;
};
if let Some(name) = text.first_child().and_downcast::<gtk4::Label>() {
name.set_label(&entry.name);
}
if let Some(meta) = text.last_child().and_downcast::<gtk4::Label>() {
let kind = if entry.is_dir {
"Folder".to_string()
} else {
human_bytes(entry.size)
};
meta.set_label(&format!("{kind} · {}", format_modified(entry.modified)));
}
});
list.set_factory(Some(&factory));
let ui_empty = ui.clone();
empty.connect_clicked(move |_| prompt_empty_trash(&ui_empty));
}
/// The [`DirEntry`] a list item is currently bound to, or `None` for an unbound
/// (recycled) row.
pub(crate) fn bound_entry(item: &gtk4::ListItem) -> Option<DirEntry> {
let obj = item.item().and_downcast::<BoxedAnyObject>()?;
let entry = obj.borrow::<DirEntry>().clone();
Some(entry)
}
/// Fetch the trash listing and repaint the page.
pub(crate) fn load_trash(ui: &Rc<Ui>) {
// Drop the old rows first: a stale row here would offer Restore on something
// that may already be gone.
ui.trash.model.remove_all();
ui.trash.empty.set_sensitive(false);
trash_status(
ui,
"user-trash-symbolic",
"Loading…",
"Reading the trash.",
false,
);
ui.busy_begin();
let rx = spawn_request(ui.dirs.control_socket(), Request::ListTrash);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
match result {
Ok(Ok(Response::Entries { entries })) => repaint_trash(&ui, &entries),
Ok(Ok(Response::Error { message, .. })) => trash_status(
&ui,
"dialog-warning-symbolic",
"Couldn't read the trash",
&message,
false,
),
Ok(Ok(_)) => trash_status(
&ui,
"dialog-warning-symbolic",
"Couldn't read the trash",
"Unexpected reply from the mount service.",
false,
),
Ok(Err(_)) | Err(_) => trash_unreachable(&ui),
}
});
}
/// The daemon didn't answer. Same split as the Files page: still starting (poll
/// again, no button) versus actually down (Retry, which restarts the service).
pub(crate) fn trash_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() {
trash_status(
ui,
"network-offline-symbolic",
"Not connected",
"The Proton Drive mount service isn't running.",
true,
);
return;
}
trash_status(
ui,
"folder-remote-symbolic",
"Connecting…",
"Waiting for the Proton Drive mount service to come up.",
false,
);
let ui = ui.clone();
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
if ui.stack.visible_child_name().as_deref() == Some("trash") {
load_trash(&ui);
}
});
}
/// Show a status page in place of the trash list.
pub(crate) fn trash_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
ui.trash.status.set_icon_name(Some(icon));
ui.trash.status.set_title(title);
ui.trash.status.set_description(Some(description));
ui.trash.retry.set_visible(retry);
ui.trash.content.set_visible_child_name("status");
ui.trash.subtitle.set_label("");
}
/// Repopulate the trash list, most recently modified first — the order in which a
/// user looks for what they just deleted.
pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
ui.trash.model.remove_all();
ui.trash.empty.set_sensitive(!entries.is_empty());
if entries.is_empty() {
trash_status(
ui,
"user-trash-symbolic",
"Trash is empty",
"Items you delete from Proton Drive show up here.",
false,
);
return;
}
ui.trash.content.set_visible_child_name("list");
ui.trash.subtitle.set_label(&match entries.len() {
1 => "1 item".to_string(),
n => format!("{n} items"),
});
let mut sorted = entries.to_vec();
sorted.sort_by_key(|e| std::cmp::Reverse(e.modified));
for entry in sorted {
ui.trash.model.append(&BoxedAnyObject::new(entry));
}
}
/// Restore one trashed entry to the folder it was trashed from.
pub(crate) fn restore_entry(ui: &Rc<Ui>, entry: &DirEntry) {
let name = entry.name.clone();
run_mutation(
ui,
Request::Restore {
uids: vec![entry.uid.clone()],
},
format!("Restored “{name}”"),
"Couldn't restore",
);
}
/// Confirm, then permanently delete one trashed entry. Irreversible, so it asks.
pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entry: &DirEntry) {
let win = ui_window(ui);
let uid = entry.uid.clone();
let name = entry.name.clone();
let dialog = adw::AlertDialog::builder()
.heading("Delete permanently")
.body(format!(
"Permanently delete “{name}”? This cannot be undone."
))
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("delete", "Delete Permanently");
dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
dialog.connect_response(None, move |_, resp| {
if resp == "delete" {
run_mutation(
&ui,
Request::DeleteForever {
uids: vec![uid.clone()],
},
format!("Deleted “{name}” permanently"),
"Couldn't delete",
);
}
});
dialog.present(win.as_ref());
}
/// Confirm, then permanently delete everything in the trash.
pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
let win = ui_window(ui);
let count = ui.trash.model.n_items();
let dialog = adw::AlertDialog::builder()
.heading("Empty Trash")
.body(format!(
"Permanently delete all {count} item(s) in the trash? This cannot be undone."
))
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("empty", "Empty Trash");
dialog.set_response_appearance("empty", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
dialog.connect_response(None, move |_, resp| {
if resp == "empty" {
run_mutation(
&ui,
Request::EmptyTrash,
"Trash emptied".to_string(),
"Couldn't empty the trash",
);
}
});
dialog.present(win.as_ref());
}
+269
View File
@@ -0,0 +1,269 @@
use crate::*;
pub(crate) struct DetailsState {
/// Widgets in the details pane, repainted from the selected entry.
pub(crate) details: DetailsWidgets,
/// The entry the details pane is currently showing, so its buttons act on the
/// same one the user is looking at even after the model is repopulated.
pub(crate) details_entry: RefCell<Option<DirEntry>>,
/// Set while the details pane is being populated, so setting the offline
/// switch programmatically doesn't fire a pin/unpin round-trip.
pub(crate) details_suppress: Cell<bool>,
/// The grid's and list's selection models. Both wrap the one browser model, so
/// a selection in either drives the details pane.
pub(crate) grid_selection: gtk4::SingleSelection,
pub(crate) list_selection: gtk4::SingleSelection,
}
/// The widgets in the browser's details pane that a selection repaints.
pub(crate) struct DetailsWidgets {
pub(crate) icon: gtk4::Image,
pub(crate) name: gtk4::Label,
pub(crate) kind: gtk4::Label,
pub(crate) size_row: adw::ActionRow,
pub(crate) modified_row: adw::ActionRow,
pub(crate) path_row: adw::ActionRow,
pub(crate) pin_row: adw::SwitchRow,
pub(crate) open_button: gtk4::Button,
pub(crate) rename_button: gtk4::Button,
pub(crate) trash_button: gtk4::Button,
pub(crate) close_button: gtk4::Button,
}
/// The details pane shown beside the file views: a big type icon over the entry's
/// name, its properties, an offline (pin) toggle and the primary actions. Built
/// empty; [`repaint_details`] fills it from the selected [`DirEntry`] and
/// [`wire_details`] connects the buttons.
pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
let close_button = gtk4::Button::builder()
.icon_name("window-close-symbolic")
.tooltip_text("Close details")
.halign(gtk4::Align::End)
.build();
close_button.add_css_class("flat");
close_button.add_css_class("circular");
let icon = gtk4::Image::builder()
.icon_name("text-x-generic-symbolic")
.pixel_size(64)
.build();
let name = gtk4::Label::builder()
.wrap(true)
.wrap_mode(gtk4::pango::WrapMode::WordChar)
.justify(gtk4::Justification::Center)
.build();
name.add_css_class("title-4");
let kind = gtk4::Label::new(None);
kind.add_css_class("dim-label");
kind.add_css_class("caption");
let head = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
head.set_margin_bottom(6);
head.append(&icon);
head.append(&name);
head.append(&kind);
let props = adw::PreferencesGroup::new();
let size_row = adw::ActionRow::builder()
.title("Size")
.subtitle("—")
.build();
size_row.add_css_class("property");
let modified_row = adw::ActionRow::builder()
.title("Modified")
.subtitle("—")
.build();
modified_row.add_css_class("property");
let path_row = adw::ActionRow::builder()
.title("Location")
.subtitle("—")
.subtitle_lines(3)
.build();
path_row.add_css_class("property");
props.add(&size_row);
props.add(&modified_row);
props.add(&path_row);
let offline = adw::PreferencesGroup::new();
let pin_row = adw::SwitchRow::builder()
.title("Available offline")
.subtitle("Keep a local copy on this device.")
.build();
offline.add(&pin_row);
let open_button = gtk4::Button::builder().label("Open").build();
open_button.add_css_class("suggested-action");
open_button.add_css_class("pill");
let rename_button = gtk4::Button::builder().label("Rename").build();
rename_button.add_css_class("pill");
let trash_button = gtk4::Button::builder().label("Move to Trash").build();
trash_button.add_css_class("destructive-action");
trash_button.add_css_class("pill");
let actions = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
actions.set_margin_top(6);
actions.append(&open_button);
actions.append(&rename_button);
actions.append(&trash_button);
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12);
inner.set_margin_bottom(12);
inner.set_margin_start(12);
inner.set_margin_end(12);
inner.append(&close_button);
inner.append(&head);
inner.append(&props);
inner.append(&offline);
inner.append(&actions);
let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never)
.child(&inner)
.build();
let pane = adw::ToolbarView::new();
pane.set_content(Some(&scroll));
pane.add_css_class("background");
(
pane.upcast(),
DetailsWidgets {
icon,
name,
kind,
size_row,
modified_row,
path_row,
pin_row,
open_button,
rename_button,
trash_button,
close_button,
},
)
}
/// Connect the details pane: mirror the grid's and list's selection into it, and
/// wire its buttons back onto the entry it's showing.
pub(crate) fn wire_details(ui: &Rc<Ui>) {
// Both views share one model but have their own selection, so watch both.
for selection in [ui.details.grid_selection.clone(), ui.details.list_selection.clone()] {
let ui_sel = ui.clone();
selection.connect_selection_changed(move |sel, _, _| {
match entry_at(sel.model().as_ref(), sel.selected()) {
Some(entry) => show_details(&ui_sel, &entry),
None => hide_details(&ui_sel),
}
});
}
let ui_close = ui.clone();
ui.details.details.close_button.connect_clicked(move |_| {
ui_close
.details.grid_selection
.set_selected(gtk4::INVALID_LIST_POSITION);
ui_close
.details.list_selection
.set_selected(gtk4::INVALID_LIST_POSITION);
hide_details(&ui_close);
});
let ui_open = ui.clone();
ui.details.details.open_button.connect_clicked(move |_| {
// Bind the clone to a local so the `Ref` from `borrow()` is dropped before
// `activate_entry` runs; it navigates and repaints the details pane, which
// takes `details_entry.borrow_mut()` and would panic against a live borrow.
let entry = ui_open.details.details_entry.borrow().clone();
if let Some(entry) = entry {
activate_entry(&ui_open, &entry);
}
});
let ui_rename = ui.clone();
ui.details.details.rename_button.connect_clicked(move |_| {
let entry = ui_rename.details.details_entry.borrow().clone();
if let Some(entry) = entry {
prompt_rename(&ui_rename, &entry);
}
});
let ui_trash = ui.clone();
ui.details.details.trash_button.connect_clicked(move |_| {
let entry = ui_trash.details.details_entry.borrow().clone();
if let Some(entry) = entry {
prompt_delete(&ui_trash, &entry);
}
});
let ui_pin = ui.clone();
ui.details.details.pin_row.connect_active_notify(move |row| {
if ui_pin.details.details_suppress.get() {
return;
}
let Some(entry) = ui_pin.details.details_entry.borrow().clone() else {
return;
};
// The switch reads the *desired* state; `toggle_pin` derives the request
// from the entry's current one, so only act when they actually differ.
if row.is_active() != entry.pinned {
toggle_pin(&ui_pin, &entry);
}
});
}
/// Reveal the details pane and paint it from `entry`.
pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
ui.details.details_suppress.set(true);
let d = &ui.details.details;
d.icon.set_icon_name(Some(icon_base_for(entry)));
d.name.set_label(&entry.name);
d.kind
.set_label(if entry.is_dir { "Folder" } else { "File" });
d.size_row.set_subtitle(&if entry.is_dir {
"—".to_string()
} else {
human_bytes(entry.size)
});
d.size_row.set_visible(!entry.is_dir);
d.modified_row
.set_subtitle(&format_modified(entry.modified));
let rel = entry_rel(ui, entry);
let parent = match rel.rfind('/') {
Some(i) => &rel[..i],
None => "",
};
d.path_row.set_subtitle(if parent.is_empty() {
"Proton Drive"
} else {
parent
});
d.pin_row.set_active(entry.pinned);
d.pin_row.set_sensitive(*ui.mounted.borrow());
d.open_button
.set_label(if entry.is_dir { "Open folder" } else { "Open" });
ui.details.details_suppress.set(false);
*ui.details.details_entry.borrow_mut() = Some(entry.clone());
// Reveal the pane on idle, not inline. This runs from `selection_changed`,
// which fires on the *first* press of a double-click; mutating the widget
// tree here cancels GtkGridView's multi-press tracking, so the second press
// restarts the count and `activate` never fires — the folder needs two
// double-clicks. Deferring lets the click gesture finish first. Guard on the
// entry still being present so a navigation that clears it wins the race.
let ui = ui.clone();
glib::idle_add_local_once(move || {
if ui.details.details_entry.borrow().is_some() {
ui.browser.split.set_show_sidebar(true);
}
});
}
/// Hide the details pane and forget the entry it was showing, so a stale entry
/// can't be acted on after the listing moves on.
pub(crate) fn hide_details(ui: &Rc<Ui>) {
ui.browser.split.set_show_sidebar(false);
*ui.details.details_entry.borrow_mut() = None;
}
/// The entry highlighted in whichever browser view is on screen, if any. Backs the
/// F2 / Delete shortcuts.
pub(crate) fn selected_entry(ui: &Rc<Ui>) -> Option<DirEntry> {
ui.details.details_entry.borrow().clone()
}
+2
View File
@@ -0,0 +1,2 @@
pub(crate) mod details;
pub(crate) mod share_dialog;
@@ -0,0 +1,461 @@
use crate::*;
/// The roles offered in the Share dialog's dropdowns, in index order.
pub(crate) const SHARE_ROLES: [&str; 3] = ["Viewer", "Editor", "Admin"];
/// Map a role dropdown index to the wire role string.
pub(crate) fn role_index_to_wire(idx: u32) -> &'static str {
match idx {
1 => "editor",
2 => "admin",
_ => "viewer",
}
}
/// Map a wire role string to its dropdown index.
pub(crate) fn role_wire_to_index(role: &str) -> u32 {
match role {
"editor" => 1,
"admin" => 2,
_ => 0,
}
}
/// The mutable state behind an open Share dialog, so the invite/role/link
/// handlers can rebuild the people and link sections after each change without
/// tearing the whole dialog down.
pub(crate) struct ShareDialog {
pub(crate) ui: Rc<Ui>,
/// The node's mountpoint-relative path — how every request addresses it.
pub(crate) rel: String,
pub(crate) people: adw::PreferencesGroup,
pub(crate) link_group: adw::PreferencesGroup,
pub(crate) people_rows: RefCell<Vec<gtk4::Widget>>,
pub(crate) link_rows: RefCell<Vec<gtk4::Widget>>,
}
/// Open the per-node Share dialog: invite Proton/external users, manage who has
/// access and their roles, and create/copy/remove a public link.
pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
if !*ui.mounted.borrow() {
toast_error(ui, "Can't share", "Proton Drive isn't connected.");
return;
}
let rel = entry_rel(ui, entry);
let toolbar = adw::ToolbarView::new();
let header = adw::HeaderBar::new();
header.set_title_widget(Some(&adw::WindowTitle::new("Share", &entry.name)));
toolbar.add_top_bar(&header);
// Invite section: emails + role + optional message.
let invite_group = adw::PreferencesGroup::builder()
.title("Invite people")
.description("Proton and non-Proton email addresses, separated by spaces or commas.")
.build();
let email_row = adw::EntryRow::builder().title("Email addresses").build();
let role_model = gtk4::StringList::new(&SHARE_ROLES);
let role_drop = gtk4::DropDown::builder()
.model(&role_model)
.selected(0)
.valign(gtk4::Align::Center)
.build();
let role_wrap = adw::ActionRow::builder().title("Role").build();
role_wrap.add_suffix(&role_drop);
let message_row = adw::EntryRow::builder().title("Message (optional)").build();
let invite_btn = gtk4::Button::builder()
.label("Send Invitations")
.halign(gtk4::Align::End)
.margin_top(6)
.build();
invite_btn.add_css_class("suggested-action");
let invite_wrap = adw::PreferencesRow::builder()
.activatable(false)
.child(&invite_btn)
.build();
invite_group.add(&email_row);
invite_group.add(&role_wrap);
invite_group.add(&message_row);
invite_group.add(&invite_wrap);
let people = adw::PreferencesGroup::builder()
.title("People with access")
.build();
let link_group = adw::PreferencesGroup::builder()
.title("Public link")
.build();
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
content.set_margin_top(12);
content.set_margin_bottom(12);
content.set_margin_start(12);
content.set_margin_end(12);
content.append(&invite_group);
content.append(&people);
content.append(&link_group);
let clamp = adw::Clamp::builder().child(&content).build();
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.hscrollbar_policy(gtk4::PolicyType::Never)
.child(&clamp)
.build();
toolbar.set_content(Some(&scroll));
let dialog = adw::Dialog::builder()
.title("Share")
.content_width(480)
.content_height(560)
.child(&toolbar)
.build();
let state = Rc::new(ShareDialog {
ui: ui.clone(),
rel,
people,
link_group,
people_rows: RefCell::new(Vec::new()),
link_rows: RefCell::new(Vec::new()),
});
// Enter in either free-text field sends the invitations, no mouse needed.
let btn = invite_btn.clone();
email_row.connect_entry_activated(move |_| btn.emit_clicked());
let btn = invite_btn.clone();
message_row.connect_entry_activated(move |_| btn.emit_clicked());
// Invite button.
let state_inv = state.clone();
invite_btn.connect_clicked(move |_| {
let raw = email_row.text();
let emails: Vec<String> = raw
.split([',', ' ', ';'])
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
if emails.is_empty() {
toast_error(&state_inv.ui, "Couldn't share", "Enter at least one email.");
return;
}
let role = role_index_to_wire(role_drop.selected()).to_string();
let msg = message_row.text().trim().to_string();
let message = if msg.is_empty() { None } else { Some(msg) };
let email_clear = email_row.clone();
let msg_clear = message_row.clone();
share_dialog_op(
&state_inv,
Request::ShareNode {
path: state_inv.rel.clone(),
emails,
role,
message,
},
"Invitations sent",
"Couldn't send invitations",
Some(Box::new(move || {
email_clear.set_text("");
msg_clear.set_text("");
})),
);
});
share_dialog_reload(&state);
dialog.present(ui_window(ui).as_ref());
}
/// Re-fetch the node's share and rebuild the people + public-link sections.
pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
let rx = spawn_request(
state.ui.dirs.control_socket(),
Request::ListShare {
path: state.rel.clone(),
},
);
let state = state.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::Share { entries, link })) => {
repaint_share_people(&state, &entries);
repaint_share_link(&state, link.as_ref());
}
Ok(Ok(Response::Error { message, .. })) => {
toast_error(&state.ui, "Couldn't load sharing", &message)
}
_ => toast_error(
&state.ui,
"Couldn't load sharing",
"The mount service didn't respond.",
),
}
});
}
/// Rebuild the "People with access" rows from a fresh share listing.
pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntry]) {
for row in state.people_rows.borrow_mut().drain(..) {
state.people.remove(&row);
}
let mut rows: Vec<gtk4::Widget> = Vec::new();
if entries.is_empty() {
let row = dim_row("No one else has access yet.");
state.people.add(&row);
rows.push(row.upcast());
*state.people_rows.borrow_mut() = rows;
return;
}
for entry in entries {
let subtitle = match entry.kind {
ShareEntryKind::Member => "Member".to_string(),
ShareEntryKind::ProtonInvite => "Invited (pending)".to_string(),
ShareEntryKind::ExternalInvite => "Invited (external, pending)".to_string(),
};
let row = adw::ActionRow::builder()
.title(&entry.email)
.subtitle(&subtitle)
.build();
// External invites can't have their role changed; show it read-only.
// Members and Proton invites get a role dropdown.
if matches!(
entry.kind,
ShareEntryKind::Member | ShareEntryKind::ProtonInvite
) {
let model = gtk4::StringList::new(&SHARE_ROLES);
let drop = gtk4::DropDown::builder()
.model(&model)
.selected(role_wire_to_index(&entry.role))
.valign(gtk4::Align::Center)
.build();
let state_role = state.clone();
let id = entry.id.clone();
let kind = entry.kind;
drop.connect_selected_notify(move |d| {
share_dialog_op(
&state_role,
Request::UpdateShareRole {
path: state_role.rel.clone(),
id: id.clone(),
kind,
role: role_index_to_wire(d.selected()).to_string(),
},
"Role updated",
"Couldn't update the role",
None,
);
});
row.add_suffix(&drop);
} else {
let label = gtk4::Label::builder()
.label(capitalize(&entry.role))
.valign(gtk4::Align::Center)
.build();
label.add_css_class("dim-label");
row.add_suffix(&label);
}
let remove = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Remove access")
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
let state_rm = state.clone();
let id = entry.id.clone();
let kind = entry.kind;
remove.connect_clicked(move |_| {
share_dialog_op(
&state_rm,
Request::RemoveShareEntry {
path: state_rm.rel.clone(),
id: id.clone(),
kind,
},
"Access removed",
"Couldn't remove access",
None,
);
});
row.add_suffix(&remove);
state.people.add(&row);
rows.push(row.upcast());
}
*state.people_rows.borrow_mut() = rows;
}
/// Rebuild the public-link section: an existing link (copy / remove) or a
/// create control.
pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLinkInfo>) {
for row in state.link_rows.borrow_mut().drain(..) {
state.link_group.remove(&row);
}
let mut rows: Vec<gtk4::Widget> = Vec::new();
match link {
Some(link) => {
let url = link.url.clone().unwrap_or_default();
let subtitle = if link.has_password {
format!("Anyone with the link ({}) · password-protected", link.role)
} else {
format!("Anyone with the link ({})", link.role)
};
let row = adw::ActionRow::builder()
.title(if url.is_empty() { "Public link" } else { &url })
.subtitle(&subtitle)
.build();
row.add_css_class("property");
if !url.is_empty() {
let copy = gtk4::Button::builder()
.icon_name("edit-copy-symbolic")
.tooltip_text("Copy link")
.valign(gtk4::Align::Center)
.build();
copy.add_css_class("flat");
let state_copy = state.clone();
let url_copy = url.clone();
copy.connect_clicked(move |btn| {
btn.clipboard().set_text(&url_copy);
toast(&state_copy.ui, "Link copied");
});
row.add_suffix(&copy);
}
let remove = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Remove link")
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
let state_rm = state.clone();
let id = link.id.clone();
remove.connect_clicked(move |_| {
share_dialog_op(
&state_rm,
Request::RemovePublicLink {
path: state_rm.rel.clone(),
id: id.clone(),
},
"Public link removed",
"Couldn't remove the link",
None,
);
});
row.add_suffix(&remove);
state.link_group.add(&row);
rows.push(row.upcast());
}
None => {
let role_model = gtk4::StringList::new(&["Viewer", "Editor"]);
let role_drop = gtk4::DropDown::builder()
.model(&role_model)
.selected(0)
.valign(gtk4::Align::Center)
.build();
let role_row = adw::ActionRow::builder().title("Link role").build();
role_row.add_suffix(&role_drop);
let pw_row = adw::PasswordEntryRow::builder()
.title("Password (optional)")
.build();
let create = gtk4::Button::builder()
.label("Create Public Link")
.halign(gtk4::Align::End)
.margin_top(6)
.build();
create.add_css_class("suggested-action");
let create_wrap = adw::PreferencesRow::builder()
.activatable(false)
.child(&create)
.build();
// Enter in the password field creates the link.
let btn = create.clone();
pw_row.connect_entry_activated(move |_| btn.emit_clicked());
let state_c = state.clone();
let pw_for = pw_row.clone();
create.connect_clicked(move |_| {
let role = if role_drop.selected() == 1 {
"editor"
} else {
"viewer"
}
.to_string();
let pw = pw_for.text().to_string();
let password = if pw.is_empty() { None } else { Some(pw) };
share_dialog_create_link(&state_c, role, password);
});
state.link_group.add(&role_row);
state.link_group.add(&pw_row);
state.link_group.add(&create_wrap);
rows.push(role_row.upcast());
rows.push(pw_row.upcast());
rows.push(create_wrap.upcast());
}
}
*state.link_rows.borrow_mut() = rows;
}
/// Create a public link, then reload the dialog so the copy/remove controls
/// replace the create form (and the freshly minted URL is shown).
pub(crate) fn share_dialog_create_link(state: &Rc<ShareDialog>, role: String, password: Option<String>) {
let rx = spawn_request(
state.ui.dirs.control_socket(),
Request::CreatePublicLink {
path: state.rel.clone(),
role,
password,
expires: None,
},
);
let state = state.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::PublicLink { .. })) => {
toast(&state.ui, "Public link created");
share_dialog_reload(&state);
}
Ok(Ok(Response::Error { message, .. })) => {
toast_error(&state.ui, "Couldn't create the link", &message)
}
_ => toast_error(
&state.ui,
"Couldn't create the link",
"The mount service didn't respond.",
),
}
});
}
/// Run a Share-dialog mutation, then reload the dialog on success. `on_success`
/// runs an extra UI tweak (e.g. clearing the invite fields) before the reload.
pub(crate) fn share_dialog_op(
state: &Rc<ShareDialog>,
req: Request,
done: &'static str,
failed: &'static str,
on_success: Option<Box<dyn Fn()>>,
) {
let rx = spawn_request(state.ui.dirs.control_socket(), req);
let state = state.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => {
if let Some(cb) = on_success {
cb();
}
toast(&state.ui, done);
share_dialog_reload(&state);
}
Ok(Ok(Response::Error { message, .. })) => toast_error(&state.ui, failed, &message),
_ => {
// A role dropdown that failed is now out of sync with the server;
// reload to snap it back.
toast_error(&state.ui, failed, "The mount service didn't respond.");
share_dialog_reload(&state);
}
}
});
}
+1 -1
View File
@@ -977,7 +977,7 @@ impl Ui {
xdg_open(Path::new(&path));
window.close();
}
Ok(Ok(Response::Error { message })) => {
Ok(Ok(Response::Error { message, .. })) => {
ui.hint.set_label(&format!("Could not open: {message}"));
}
_ => ui.hint.set_label("Could not reach the daemon"),