mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
refactor, fixed monolithic
This commit is contained in:
1 parent
232b3b5348
commit
7766628098
28 files changed
+9242
-8854
No files matched your search
+42
-28
@@ -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(())
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 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}"))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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: >k4::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(>k4::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
@@ -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: >k4::Button, add_folder: >k4::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(>k4::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(>k4::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(>k4::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(>k4::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."),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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: >k4::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);
|
||||
});
|
||||
}
|
||||
@@ -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: >k4::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: >k4::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
@@ -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: >k4::Button, add_bookmark: >k4::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(>k4::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(>k4::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(>k4::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: >k4::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(>k4::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(©);
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -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: >k4::Button, mountpoint_button: >k4::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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(>k4::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: >k4::ListView, empty: >k4::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: >k4::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());
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(©);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
Reference in new issue
Block a user