mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
fixed some bugs
This commit is contained in:
1 parent
7766628098
commit
a90cf83ff5
39 files changed
+555
-267
No files matched your search
@@ -11,9 +11,8 @@ 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,
|
||||
ErrorKind, RefreshScope, Request as CtlRequest, Response as CtlResponse, ShareEntryKind,
|
||||
SyncPhase, pending_summary,
|
||||
};
|
||||
use pdfs_core::db::Db;
|
||||
|
||||
@@ -361,6 +360,9 @@ enum BookmarkCmd {
|
||||
fn cli_error(kind: ErrorKind, message: &str) -> String {
|
||||
match kind {
|
||||
ErrorKind::Offline => "offline: the Proton Drive API is unreachable".to_string(),
|
||||
// The daemon's wording names the failed call ("upload x: ..."), which
|
||||
// does not say the thing the user has to act on.
|
||||
ErrorKind::Quota => format!("out of storage: {message}"),
|
||||
_ => message.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -927,6 +927,10 @@ pub enum ErrorKind {
|
||||
/// The API was reached and refused, or the transfer broke. The one class
|
||||
/// where an unchanged retry can legitimately succeed.
|
||||
Remote,
|
||||
/// The account is out of storage. Distinct from [`Denied`](Self::Denied)
|
||||
/// because the user *can* fix it, and distinct from [`Remote`](Self::Remote)
|
||||
/// because retrying an upload that did not fit will not make it fit.
|
||||
Quota,
|
||||
/// Something on this machine failed: the database, the content cache, the
|
||||
/// filesystem. Not the user's doing and not theirs to fix.
|
||||
#[default]
|
||||
|
||||
@@ -63,5 +63,4 @@ impl Db {
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -122,5 +122,4 @@ impl Db {
|
||||
// keyed by uid display string. `recursive` marks a folder pin: its whole
|
||||
// subtree counts as pinned, resolved against `nodes` via a CTE so a new
|
||||
// descendant is covered the moment it lands in the node cache.
|
||||
|
||||
}
|
||||
@@ -44,5 +44,4 @@ impl Db {
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -160,4 +160,3 @@ pub(super) fn local_hit(row: &rusqlite::Row) -> rusqlite::Result<LocalFileHit> {
|
||||
mtime: row.get(4)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
//! is already on disk somewhere, so a change here would silently diverge from
|
||||
//! what an existing install actually has. Add a new one instead.
|
||||
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
@@ -84,7 +83,6 @@ impl Db {
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Schema v1: nodes + FTS5 trigram index + cache LRU. `sync_state` is created
|
||||
@@ -329,4 +327,3 @@ const MIGRATION_V13: &str = "
|
||||
ALTER TABLE photos ADD COLUMN media_type TEXT;
|
||||
ALTER TABLE photos ADD COLUMN kind INTEGER NOT NULL DEFAULT 0;
|
||||
";
|
||||
|
||||
@@ -40,9 +40,8 @@ pub use devices::StoredDevice;
|
||||
pub use local::LocalFileHit;
|
||||
pub use nodes::{SearchHit, StoredNode};
|
||||
pub use ops::{
|
||||
LOCAL_VOLUME,
|
||||
AttachedBlob, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingCounts,
|
||||
PendingOp, op_supersedes,
|
||||
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH,
|
||||
PendingCounts, PendingOp, op_supersedes,
|
||||
};
|
||||
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN};
|
||||
pub use sync::{StoredSyncEntry, StoredSyncFolder};
|
||||
|
||||
@@ -223,10 +223,8 @@ impl Db {
|
||||
// separate. `last_accessed` (unix seconds) is the LRU key. The daemon owns
|
||||
// the on-disk cache and rebuilds this index from disk on open, then keeps it
|
||||
// in sync on every store/read/evict, so it is authoritative for eviction.
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Effective plaintext size of a node for the indexed `size` column: the
|
||||
/// claimed size when known, else the on-storage size; folders are 0.
|
||||
pub(super) fn node_size(node: &Node) -> i64 {
|
||||
|
||||
@@ -340,5 +340,4 @@ pub const LOCAL_VOLUME: &str = "local";
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -47,8 +47,7 @@ impl Db {
|
||||
// any previously learned value so the Photos/Videos/Raw split survives a
|
||||
// refresh instead of collapsing back to name-extension guesses.
|
||||
let learned: HashMap<String, (Option<f64>, i64, Option<String>)> = {
|
||||
let mut stmt =
|
||||
tx.prepare("SELECT uid, ratio, thumb_state, media_type FROM photos")?;
|
||||
let mut stmt = tx.prepare("SELECT uid, ratio, thumb_state, media_type FROM photos")?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?, r.get(3)?)))
|
||||
})?;
|
||||
@@ -63,7 +62,8 @@ impl Db {
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
)?;
|
||||
for (seq, (uid, capture_time, name, media_type)) in items.iter().enumerate() {
|
||||
let (ratio, thumb_state, learned_media) = learned
|
||||
let (ratio, thumb_state, learned_media) =
|
||||
learned
|
||||
.get(uid)
|
||||
.cloned()
|
||||
.unwrap_or((None, THUMB_UNKNOWN, None));
|
||||
@@ -71,10 +71,8 @@ impl Db {
|
||||
// The tab this photo lands in is derived here, once, so a page or
|
||||
// count query is a plain indexed `WHERE kind = ?` rather than a
|
||||
// reclassification of every row.
|
||||
let kind = crate::control::PhotoKind::classify(
|
||||
name.as_deref(),
|
||||
media_type.as_deref(),
|
||||
);
|
||||
let kind =
|
||||
crate::control::PhotoKind::classify(name.as_deref(), media_type.as_deref());
|
||||
stmt.execute(params![
|
||||
uid,
|
||||
capture_time,
|
||||
@@ -127,7 +125,9 @@ impl Db {
|
||||
let limit_pos = binds.len();
|
||||
binds.push(offset as i64);
|
||||
let offset_pos = binds.len();
|
||||
sql.push_str(&format!(" ORDER BY seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}"));
|
||||
sql.push_str(&format!(
|
||||
" ORDER BY seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}"
|
||||
));
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
@@ -168,7 +168,11 @@ impl Db {
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(binds), |r| {
|
||||
Ok((r.get::<_, i32>(0)?, r.get::<_, i32>(1)?, r.get::<_, i64>(2)? as usize))
|
||||
Ok((
|
||||
r.get::<_, i32>(0)?,
|
||||
r.get::<_, i32>(1)?,
|
||||
r.get::<_, i64>(2)? as usize,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
@@ -240,5 +244,4 @@ impl Db {
|
||||
let n: i64 = conn.query_row("SELECT COUNT(*) FROM photos", [], |r| r.get(0))?;
|
||||
Ok(n.max(0) as usize)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,8 +6,6 @@ use rusqlite::{OptionalExtension, params};
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
|
||||
|
||||
impl Db {
|
||||
pub fn pin_add(&self, uid: &str, path: &str, recursive: bool) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
@@ -120,5 +118,4 @@ impl Db {
|
||||
}
|
||||
|
||||
// ---- local (non-Drive) file index (schema v6) ----
|
||||
|
||||
}
|
||||
@@ -94,5 +94,4 @@ impl Db {
|
||||
conn.execute("DELETE FROM sync_state WHERE key = ?1", params![key])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -222,5 +222,4 @@ impl Db {
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
use super::*;
|
||||
use super::activity::ACTIVITY_KEEP;
|
||||
use super::migrations::SCHEMA_VERSION;
|
||||
use super::*;
|
||||
use crate::control::{ActivityEntry, ActivityKind};
|
||||
use crate::localindex::LocalEntry;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use proton_drive_rs::{Node, NodeKind};
|
||||
|
||||
|
||||
/// `Db::open` applies the pragmas that `open_in_memory` skips, so the WAL
|
||||
/// settings can only be checked against a real file.
|
||||
#[test]
|
||||
@@ -39,12 +38,7 @@ use proton_drive_rs::{Node, NodeKind};
|
||||
|
||||
// `NodeVerification` is not re-exported, so build test nodes by
|
||||
// deserializing JSON (the field has a serde default and can be omitted).
|
||||
fn node_from(
|
||||
parent: serde_json::Value,
|
||||
link: &str,
|
||||
name: &str,
|
||||
kind: serde_json::Value,
|
||||
) -> Node {
|
||||
fn node_from(parent: serde_json::Value, link: &str, name: &str, kind: serde_json::Value) -> Node {
|
||||
let v = serde_json::json!({
|
||||
"uid": {"volume_id": "vol", "link_id": link},
|
||||
"parent_uid": parent,
|
||||
@@ -122,10 +116,18 @@ use proton_drive_rs::{Node, NodeKind};
|
||||
["p1", "p2"]
|
||||
);
|
||||
let ranged_video = db
|
||||
.photos_page(0, 10, Some(crate::control::PhotoKind::Video), Some((150, 350)))
|
||||
.photos_page(
|
||||
0,
|
||||
10,
|
||||
Some(crate::control::PhotoKind::Video),
|
||||
Some((150, 350)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ranged_video.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
ranged_video
|
||||
.iter()
|
||||
.map(|p| p.uid.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["p1"]
|
||||
);
|
||||
// All three surviving photos sit in the same (1970-01) local month.
|
||||
|
||||
@@ -61,5 +61,4 @@ impl Db {
|
||||
}
|
||||
|
||||
// ---- device sync (devices.md) -----------------------------------------
|
||||
|
||||
}
|
||||
@@ -93,6 +93,27 @@ impl CoreError {
|
||||
Self::new(ErrorKind::Internal, what)
|
||||
}
|
||||
|
||||
/// Classify a failure from an SDK call, prefixing `what` for the log.
|
||||
///
|
||||
/// Takes `&dyn Error` rather than `&ProtonError` so it reads the same
|
||||
/// through the boxes the drain deals in, where the concrete type survives
|
||||
/// but the static one does not.
|
||||
///
|
||||
/// A failure that is not an API response at all — a transport error, a
|
||||
/// timeout, a TLS failure — is what being offline looks like from here, so
|
||||
/// it classifies as [`ErrorKind::Offline`] rather than
|
||||
/// [`ErrorKind::Remote`]: the request never reached Proton, and telling the
|
||||
/// user their connection is down beats telling them the server refused.
|
||||
pub fn from_api(e: &(dyn std::error::Error + 'static), what: &str) -> Self {
|
||||
let kind = match e.downcast_ref::<proton_sdk::ProtonError>() {
|
||||
Some(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
|
||||
// A `ProtonError` that is not an `Api` variant never reached the
|
||||
// API; so does anything that is not a `ProtonError` at all.
|
||||
_ => ErrorKind::Offline,
|
||||
};
|
||||
Self::new(kind, format!("{what}: {e}"))
|
||||
}
|
||||
|
||||
/// Prefix the message while keeping the classification.
|
||||
///
|
||||
/// For adding the caller's context to an already-classified failure — the
|
||||
@@ -134,12 +155,136 @@ impl From<Error> for CoreError {
|
||||
fn from(e: Error) -> Self {
|
||||
let kind = match &e {
|
||||
Error::NotLoggedIn | Error::ReloginRequired => ErrorKind::Denied,
|
||||
Error::Proton(_) => ErrorKind::Remote,
|
||||
Error::Proton(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
|
||||
// A `ProtonError` that never reached the API is a transport failure.
|
||||
Error::Proton(_) => ErrorKind::Offline,
|
||||
_ => ErrorKind::Internal,
|
||||
};
|
||||
Self::new(kind, e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// What an API response code means to a front-end.
|
||||
///
|
||||
/// The grouping is by *what the user can do about it*, which is what
|
||||
/// [`ErrorKind`] exists to express — not by HTTP-ish similarity. So a 403 and a
|
||||
/// `NotEnoughPermissions` land together despite being different numbers, while
|
||||
/// `AlreadyExists` and `DoesNotExist` stay apart despite both being 25xx.
|
||||
fn api_kind(code: proton_sdk::api::ResponseCode) -> ErrorKind {
|
||||
use proton_sdk::api::ResponseCode as C;
|
||||
match code {
|
||||
// The account may not do this, and repeating it will not help.
|
||||
C::Unauthorized
|
||||
| C::Forbidden
|
||||
| C::NotEnoughPermissions
|
||||
| C::NotEnoughPermissionsToGrantPermissions
|
||||
| C::InsufficientScope
|
||||
| C::IncorrectLoginCredentials
|
||||
| C::InvalidRefreshToken
|
||||
| C::AccountDeleted
|
||||
| C::AccountDisabled
|
||||
| C::NoActiveSubscription => ErrorKind::Denied,
|
||||
|
||||
C::DoesNotExist => ErrorKind::NotFound,
|
||||
|
||||
// The remote moved underneath the request; the caller has to decide.
|
||||
C::AlreadyExists | C::IncompatibleState => ErrorKind::Conflict,
|
||||
|
||||
// The request was malformed or asks for something structurally
|
||||
// impossible. A bug in the caller, not a condition to retry.
|
||||
C::InvalidRequirements
|
||||
| C::InvalidValue
|
||||
| C::InvalidEncryptedIdFormat
|
||||
| C::InvalidApp
|
||||
| C::TooManyChildren
|
||||
| C::NestingTooDeep => ErrorKind::Invalid,
|
||||
|
||||
C::InsufficientQuota | C::InsufficientSpace | C::InsufficientVolumeQuota => {
|
||||
ErrorKind::Quota
|
||||
}
|
||||
|
||||
// Reached Proton, but it could not answer *now*. Retrying is exactly
|
||||
// the right response, so these are Offline rather than Remote — the
|
||||
// front-end already words that as "not right now".
|
||||
C::RequestTimeout
|
||||
| C::Timeout
|
||||
| C::TooManyRequests
|
||||
| C::ServiceUnavailable
|
||||
| C::Offline => ErrorKind::Offline,
|
||||
|
||||
// Reached Proton and it refused for a reason we have no specific
|
||||
// handling for. Retryable, because an unchanged retry can legitimately
|
||||
// succeed against a transient server-side condition.
|
||||
_ => ErrorKind::Remote,
|
||||
}
|
||||
}
|
||||
|
||||
/// The result every request-serving method in the daemon returns.
|
||||
pub type CoreResult<T> = std::result::Result<T, CoreError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proton_sdk::api::ResponseCode as C;
|
||||
use proton_sdk::{ProtonApiError, ProtonError};
|
||||
|
||||
fn api(code: C) -> ProtonError {
|
||||
ProtonError::Api(ProtonApiError {
|
||||
code,
|
||||
http_status: 422,
|
||||
message: "nope".into(),
|
||||
details: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_codes_classify_by_what_the_user_can_do() {
|
||||
assert_eq!(api_kind(C::Forbidden), ErrorKind::Denied);
|
||||
assert_eq!(api_kind(C::NotEnoughPermissions), ErrorKind::Denied);
|
||||
assert_eq!(api_kind(C::DoesNotExist), ErrorKind::NotFound);
|
||||
assert_eq!(api_kind(C::AlreadyExists), ErrorKind::Conflict);
|
||||
assert_eq!(api_kind(C::InvalidValue), ErrorKind::Invalid);
|
||||
assert_eq!(api_kind(C::InsufficientSpace), ErrorKind::Quota);
|
||||
assert_eq!(api_kind(C::TooManyRequests), ErrorKind::Offline);
|
||||
// Nothing specific known about it, but Proton did answer.
|
||||
assert_eq!(api_kind(C::ProtonDriveUnknown), ErrorKind::Remote);
|
||||
}
|
||||
|
||||
/// The regression this whole pass exists to prevent: before it, every SDK
|
||||
/// failure was hardcoded to `Remote`, so a 403 asked the user to retry a
|
||||
/// request that could never succeed.
|
||||
#[test]
|
||||
fn a_forbidden_response_is_not_offered_as_retryable() {
|
||||
let e = CoreError::from_api(&api(C::Forbidden), "rename");
|
||||
assert_eq!(e.kind, ErrorKind::Denied);
|
||||
assert!(!e.kind.retryable());
|
||||
assert!(e.message.starts_with("rename: "));
|
||||
}
|
||||
|
||||
/// Being full is not a transient condition, so retrying an upload that did
|
||||
/// not fit must not be offered as a fix.
|
||||
#[test]
|
||||
fn running_out_of_space_is_not_retryable() {
|
||||
assert!(
|
||||
!CoreError::from_api(&api(C::InsufficientQuota), "upload")
|
||||
.kind
|
||||
.retryable()
|
||||
);
|
||||
}
|
||||
|
||||
/// A failure that never reached the API is the user's connection, not
|
||||
/// Proton refusing — the front-end words those differently.
|
||||
#[test]
|
||||
fn a_transport_failure_reads_as_offline() {
|
||||
let io = std::io::Error::other("connection reset");
|
||||
assert_eq!(CoreError::from_api(&io, "list").kind, ErrorKind::Offline);
|
||||
}
|
||||
|
||||
/// `Error` is the plumbing type; converting it must classify as sharply as
|
||||
/// the direct path, or the two disagree about the same failure.
|
||||
#[test]
|
||||
fn converting_from_the_plumbing_error_classifies_the_api_code() {
|
||||
let e: CoreError = Error::Proton(api(C::DoesNotExist)).into();
|
||||
assert_eq!(e.kind, ErrorKind::NotFound);
|
||||
}
|
||||
}
|
||||
@@ -10,17 +10,17 @@
|
||||
//! would stall the GUI`s 2s status poll behind it.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write as _};
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Instant;
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Instant;
|
||||
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
ActivityKind, PhotoMonth, RefreshScope, Request as CtlRequest, Response as CtlResponse,
|
||||
TransferDirection,
|
||||
};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use tracing::{info, warn};
|
||||
|
||||
@@ -125,8 +125,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
limit,
|
||||
kind,
|
||||
range,
|
||||
}) => {
|
||||
match core.photos_timeline(offset, limit, kind, range) {
|
||||
}) => match core.photos_timeline(offset, limit, kind, range) {
|
||||
Ok(Some(items)) => CtlResponse::Photos {
|
||||
available: true,
|
||||
items,
|
||||
@@ -138,17 +137,12 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
counts: None,
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
},
|
||||
Ok(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) {
|
||||
Ok(months) => CtlResponse::PhotoMonths {
|
||||
months: months
|
||||
.into_iter()
|
||||
.map(|(year, month, count)| PhotoMonth {
|
||||
year,
|
||||
month,
|
||||
count,
|
||||
})
|
||||
.map(|(year, month, count)| PhotoMonth { year, month, count })
|
||||
.collect(),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e.into()),
|
||||
@@ -207,7 +201,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
message: format!("uploaded photo with uid {uid}"),
|
||||
}
|
||||
}
|
||||
Err(e) => CtlResponse::error(CoreError::remote(format!("upload photo failed: {e}"))),
|
||||
Err(e) => CtlResponse::error(CoreError::from_api(&e, "upload photo failed")),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::OpenFile { path }) => match rel_to_mount(mountpoint, &path) {
|
||||
@@ -688,7 +682,12 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
/// stall every other caller behind it — the GUI's 2s status poll, and the
|
||||
/// thumbnail batches the gallery needs to paint. [`Core`] is a bundle of handles
|
||||
/// (`Arc`/`Clone`), so each connection gets its own copy of it.
|
||||
pub(crate) fn run_control_socket(core: Core, username: String, mountpoint: PathBuf, listener: UnixListener) {
|
||||
pub(crate) fn run_control_socket(
|
||||
core: Core,
|
||||
username: String,
|
||||
mountpoint: PathBuf,
|
||||
listener: UnixListener,
|
||||
) {
|
||||
info!("control socket listening");
|
||||
for conn in listener.incoming() {
|
||||
match conn {
|
||||
|
||||
@@ -11,20 +11,22 @@ use std::collections::HashMap;
|
||||
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};
|
||||
use proton_drive_rs::{DeviceType, Node};
|
||||
use parking_lot::Mutex;
|
||||
use pdfs_core::control::{
|
||||
ActivityKind, DeviceInfo, JobItem, SyncFolderInfo, SyncPhase, SyncProgress,
|
||||
};
|
||||
use pdfs_core::db::{StoredDevice, StoredSyncFolder};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid};
|
||||
use proton_drive_rs::{DeviceType, Node};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::{
|
||||
BackgroundSession, Core, ProtonFs, State, SwitchBlocked, clear_stale_mount, device_type_str, dir_is_empty,
|
||||
evict_dir_contents, now_secs, parse_uid, sync_folder_info, this_hostname,
|
||||
};
|
||||
use super::sync::{self, base_name};
|
||||
use super::{
|
||||
BackgroundSession, Core, ProtonFs, State, SwitchBlocked, clear_stale_mount, device_type_str,
|
||||
dir_is_empty, evict_dir_contents, now_secs, parse_uid, sync_folder_info, this_hostname,
|
||||
};
|
||||
|
||||
impl Core {
|
||||
// ---- devices ----------------------------------------------------------
|
||||
@@ -36,7 +38,7 @@ impl Core {
|
||||
let devices = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_devices())
|
||||
.map_err(|e| CoreError::remote(format!("list devices: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "list devices"))?;
|
||||
// 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);
|
||||
@@ -63,7 +65,7 @@ impl Core {
|
||||
let device_uid = DeviceUid::from(uid);
|
||||
self.rt
|
||||
.block_on(self.client.rename_device(&device_uid, name))
|
||||
.map_err(|e| CoreError::remote(format!("rename device: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "rename device"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -72,7 +74,7 @@ impl Core {
|
||||
let device_uid = DeviceUid::from(uid);
|
||||
self.rt
|
||||
.block_on(self.client.delete_device(&device_uid))
|
||||
.map_err(|e| CoreError::remote(format!("delete device: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "delete device"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -89,13 +91,17 @@ impl Core {
|
||||
let remote = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_devices())
|
||||
.map_err(|e| CoreError::remote(format!("enumerate devices: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
||||
|
||||
// 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| CoreError::internal(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);
|
||||
}
|
||||
@@ -118,7 +124,7 @@ impl Core {
|
||||
let d = self
|
||||
.rt
|
||||
.block_on(self.client.create_device(&name, DeviceType::Linux))
|
||||
.map_err(|e| CoreError::remote(format!("create device: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "create device"))?;
|
||||
StoredDevice {
|
||||
uid: d.uid.to_string(),
|
||||
share_id: d.share_id.to_string(),
|
||||
@@ -128,7 +134,9 @@ impl Core {
|
||||
}
|
||||
}
|
||||
};
|
||||
self.db.device_set(&dev).map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
self.db
|
||||
.device_set(&dev)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
Ok(dev)
|
||||
}
|
||||
|
||||
@@ -142,14 +150,14 @@ impl Core {
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_folder_children_node_uids(root_uid))
|
||||
.map_err(|e| CoreError::remote(format!("list device root: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
||||
if uids.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::remote(format!("resolve device root children: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?;
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
.find(|n| n.is_folder() && !n.trashed && n.name == name)
|
||||
@@ -161,10 +169,13 @@ impl Core {
|
||||
/// 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) -> CoreResult<StoredSyncFolder> {
|
||||
let meta =
|
||||
std::fs::metadata(local).map_err(|e| CoreError::internal(format!("stat {}: {e}", local.display())))?;
|
||||
let meta = std::fs::metadata(local)
|
||||
.map_err(|e| CoreError::internal(format!("stat {}: {e}", local.display())))?;
|
||||
if !meta.is_dir() {
|
||||
return Err(CoreError::invalid(format!("{} is not a directory", local.display())));
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{} is not a directory",
|
||||
local.display()
|
||||
)));
|
||||
}
|
||||
let local = local
|
||||
.canonicalize()
|
||||
@@ -179,18 +190,24 @@ impl Core {
|
||||
.iter()
|
||||
.any(|f| f.local_path == local_str)
|
||||
{
|
||||
return Err(CoreError::invalid(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(|| CoreError::invalid(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(|| CoreError::internal(format!("bad device root uid: {}", device.root_uid)))?;
|
||||
let root_uid = parse_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
|
||||
@@ -210,7 +227,7 @@ impl Core {
|
||||
self.client
|
||||
.create_folder(&root_uid, &name, Some(now_secs())),
|
||||
)
|
||||
.map_err(|e| CoreError::remote(format!("create device folder {name}: {e}")))?,
|
||||
.map_err(|e| CoreError::from_api(&e, &format!("create device folder {name}")))?,
|
||||
};
|
||||
|
||||
let id = self
|
||||
@@ -312,11 +329,7 @@ impl Core {
|
||||
|
||||
/// The lock guarding sync-folder `id` against concurrent reconcile/mode-switch.
|
||||
pub(crate) fn sync_lock(&self, id: i64) -> Arc<Mutex<()>> {
|
||||
self.sync_locks
|
||||
.lock()
|
||||
.entry(id)
|
||||
.or_default()
|
||||
.clone()
|
||||
self.sync_locks.lock().entry(id).or_default().clone()
|
||||
}
|
||||
|
||||
/// Remove a synced folder from the sync set. `delete_remote` also deletes its
|
||||
@@ -397,7 +410,9 @@ impl Core {
|
||||
/// it is about to evict ([`Core::push_pass`]).
|
||||
pub(crate) fn request_sync_folder_mode(&self, id: i64, mode: &str) -> CoreResult<String> {
|
||||
if mode != "mirror" && mode != "ondemand" {
|
||||
return Err(CoreError::invalid(format!("unknown mode {mode:?} (want mirror|ondemand)")));
|
||||
return Err(CoreError::invalid(format!(
|
||||
"unknown mode {mode:?} (want mirror|ondemand)"
|
||||
)));
|
||||
}
|
||||
let folder = self
|
||||
.db
|
||||
@@ -493,7 +508,11 @@ impl Core {
|
||||
///
|
||||
/// [`SwitchBlocked::NotNow`] means "not yet, try after a pass" and is never an
|
||||
/// error the user needs to see — callers queue on it.
|
||||
pub(crate) fn apply_sync_folder_mode(&self, id: i64, mode: &str) -> Result<String, SwitchBlocked> {
|
||||
pub(crate) fn apply_sync_folder_mode(
|
||||
&self,
|
||||
id: i64,
|
||||
mode: &str,
|
||||
) -> Result<String, SwitchBlocked> {
|
||||
// Hold the folder's lock for the whole switch so no reconcile pass can be
|
||||
// running over the tree we are about to evict (or start while we mount over
|
||||
// it). A pass in flight holds the lock for its full duration, so `try_lock`
|
||||
@@ -588,7 +607,11 @@ 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) -> CoreResult<BackgroundSession> {
|
||||
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![
|
||||
@@ -695,5 +718,4 @@ impl Core {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -24,7 +24,6 @@ use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use pdfs_core::CoreError;
|
||||
use pdfs_core::cache::StagedWrite;
|
||||
use pdfs_core::control::{ActivityKind, TransferDirection};
|
||||
use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp};
|
||||
@@ -253,7 +252,10 @@ impl Core {
|
||||
|
||||
/// Make a node that so far exists only on this machine real, and adopt the
|
||||
/// uid the server gives it (offline.md Phase 3b).
|
||||
pub(crate) fn drain_local_node(&self, op: &PendingOp) -> Result<(), Box<dyn std::error::Error>> {
|
||||
pub(crate) fn drain_local_node(
|
||||
&self,
|
||||
op: &PendingOp,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let local = parse_node_uid(&op.uid).ok_or("pending op has an unparseable uid")?;
|
||||
let parent_str = op.parent_uid.as_deref().ok_or("create op has no parent")?;
|
||||
// `run_pending_drain` will not offer an op whose parent is still a
|
||||
@@ -395,7 +397,9 @@ impl Core {
|
||||
local: &NodeUid,
|
||||
real: &NodeUid,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let node = self.fetch_node(real).map_err(|e| CoreError::remote(format!("{e:?}")))?;
|
||||
let node = self
|
||||
.fetch_node(real)
|
||||
.map_err(|e| self.errno_error(e, "fetch node"))?;
|
||||
// Repoints queued children and node rows, and drops the placeholder row.
|
||||
self.db
|
||||
.remap_local_uid(&local.to_string(), &real.to_string())?;
|
||||
@@ -676,7 +680,7 @@ impl Core {
|
||||
meta.base_size,
|
||||
&written,
|
||||
)
|
||||
.map_err(|e| CoreError::remote(format!("gap-fill from base failed: {e:?}")))?;
|
||||
.map_err(|e| self.errno_error(e, "gap-fill from base failed"))?;
|
||||
}
|
||||
|
||||
let name = {
|
||||
@@ -766,5 +770,4 @@ impl Core {
|
||||
};
|
||||
st.intern(parent, node);
|
||||
}
|
||||
|
||||
}
|
||||
+82
-70
@@ -31,15 +31,15 @@
|
||||
//! set, so a multi-GiB write never buffers in RAM and only the untouched
|
||||
//! remainder of the file is pulled from the remote — lazily, at commit.
|
||||
|
||||
use parking_lot::{Condvar, Mutex};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::ffi::OsStr;
|
||||
use std::fs::File;
|
||||
use std::os::unix::fs::FileExt;
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use parking_lot::{Condvar, Mutex};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use fuser::ReplyXattr;
|
||||
@@ -49,18 +49,18 @@ use fuser::{
|
||||
RenameFlags, ReplyAttr, ReplyCreate, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry,
|
||||
ReplyOpen, ReplyWrite, Request, Session, TimeOrNow, WriteFlags,
|
||||
};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use pdfs_core::cache::{BLOCK_SIZE, Baseline, ContentCache, StagedWrite};
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
ActivityEntry, ActivityKind, DirEntry,
|
||||
LocalHit, PhotoKind, PublicLinkInfo, SearchHit, SyncFolderInfo,
|
||||
SyncPhase, SyncProgress, TransferDirection,
|
||||
ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo,
|
||||
SearchHit, SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection,
|
||||
};
|
||||
use pdfs_core::db::{
|
||||
Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, StoredNode, StoredSyncFolder, StoredTrash,
|
||||
Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, StoredNode,
|
||||
StoredSyncFolder, StoredTrash,
|
||||
};
|
||||
use pdfs_core::localindex;
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::proton_sdk::api::ResponseCode;
|
||||
use proton_drive_rs::proton_sdk::error::ProtonError;
|
||||
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId};
|
||||
@@ -81,8 +81,8 @@ mod transfers;
|
||||
mod workers;
|
||||
use state::{Entry, Intervals, PendingRevision, State, WriteHandle};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use workers::{FUSE_WORKERS, Workers};
|
||||
use transfers::{CountingReader, CountingWriter, JobGuard, OwnedCountingReader, TransferRegistry};
|
||||
use workers::{FUSE_WORKERS, Workers};
|
||||
|
||||
/// Attribute/entry cache lifetime handed back to the kernel. Long because the
|
||||
/// Phase 2 event poller actively invalidates changed inodes; without a remote
|
||||
@@ -284,7 +284,6 @@ async fn run_uploads(
|
||||
/// (two API calls and a node-key unlock) the next time that file is read.
|
||||
const MAX_OPEN_READERS: usize = 64;
|
||||
|
||||
|
||||
/// An open reader plus the node metadata it was opened against.
|
||||
///
|
||||
/// The SDK pins a reader to the revision that was active at `open_revision`, so
|
||||
@@ -828,23 +827,34 @@ impl Core {
|
||||
/// 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.
|
||||
self.resolve_path(rel)
|
||||
.map_err(|e| self.errno_error(e, &format!("could not resolve {}", rel.display())))
|
||||
}
|
||||
|
||||
/// Classify a failure that arrived as an `Errno`.
|
||||
///
|
||||
/// The internal paths speak `Errno` because they also serve FUSE, where a
|
||||
/// number is the whole vocabulary. Everything crossing the control socket
|
||||
/// has to be turned back into something a person can read, and this is the
|
||||
/// one place that knows how — a call site holding only an `Errno` has
|
||||
/// already lost the context needed to say what went wrong.
|
||||
///
|
||||
/// `Errno` is neither `PartialEq` nor structural-match, so this compares
|
||||
/// raw codes rather than the `libc` constants.
|
||||
fn errno_error(&self, e: Errno, what: &str) -> CoreError {
|
||||
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::ENOENT => CoreError::not_found(format!("{what}: no such file or folder")),
|
||||
libc::EACCES | libc::EPERM => CoreError::denied(format!("{what}: not allowed")),
|
||||
// These paths walk the tree lazily, so a cold node 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:?}")),
|
||||
libc::EINVAL => CoreError::invalid(format!("{what}: not a usable path")),
|
||||
libc::ENOSPC => CoreError::new(ErrorKind::Quota, format!("{what}: out of space")),
|
||||
libc::EEXIST => CoreError::conflict(format!("{what}: already exists")),
|
||||
libc::ENOTEMPTY => CoreError::conflict(format!("{what}: folder is not empty")),
|
||||
_ => CoreError::internal(format!("{what}: {e:?}")),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch a single node's current metadata from the remote.
|
||||
@@ -1155,13 +1165,11 @@ impl Core {
|
||||
while let Some(joined) = set.join_next().await {
|
||||
let Ok((bidx, read)) = joined else { continue };
|
||||
match read {
|
||||
Ok(bytes) => core.stream_ring.lock().insert(
|
||||
&uid,
|
||||
mtime,
|
||||
fsize,
|
||||
bidx,
|
||||
Arc::new(bytes),
|
||||
),
|
||||
Ok(bytes) => {
|
||||
core.stream_ring
|
||||
.lock()
|
||||
.insert(&uid, mtime, fsize, bidx, Arc::new(bytes))
|
||||
}
|
||||
Err(e) => debug!(%uid, bidx, error = %e, "stream readahead failed"),
|
||||
}
|
||||
}
|
||||
@@ -1207,11 +1215,11 @@ impl Core {
|
||||
// hold them) and before the network (which would refetch the same
|
||||
// 4 MiB block for every 128 KiB the player consumes).
|
||||
let hit = if cache_blocks {
|
||||
self.cache.cached_block(uid, mtime, fsize, bidx).map(Arc::new)
|
||||
self.cache
|
||||
.cached_block(uid, mtime, fsize, bidx)
|
||||
.map(Arc::new)
|
||||
} else {
|
||||
self.stream_ring
|
||||
.lock()
|
||||
.get(uid, mtime, fsize, bidx)
|
||||
self.stream_ring.lock().get(uid, mtime, fsize, bidx)
|
||||
};
|
||||
match hit {
|
||||
Some(b) => blocks.push(Some(b)),
|
||||
@@ -1843,7 +1851,10 @@ impl Core {
|
||||
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_else(|| CoreError::not_found("node vanished"))?;
|
||||
let e = st
|
||||
.entries
|
||||
.get(&ino)
|
||||
.ok_or_else(|| CoreError::not_found("node vanished"))?;
|
||||
(
|
||||
e.node.name.clone(),
|
||||
e.node.is_folder(),
|
||||
@@ -1856,19 +1867,19 @@ impl Core {
|
||||
// exempt before we start filling the cache with the subtree.
|
||||
self.cache
|
||||
.add_pin(&uid, rel, true)
|
||||
.map_err(|e| CoreError::remote(format!("pin: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
||||
let n = self.pin_subtree(ino)?;
|
||||
return Ok(format!("{name} ({n} files)"));
|
||||
}
|
||||
let bytes = self
|
||||
.download_file_tracked(&uid, &name, size)
|
||||
.map_err(|e| CoreError::remote(format!("download: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "download"))?;
|
||||
self.cache
|
||||
.store(&uid, mtime, size, &bytes)
|
||||
.map_err(|e| CoreError::internal(format!("cache store: {e}")))?;
|
||||
self.cache
|
||||
.add_pin(&uid, rel, false)
|
||||
.map_err(|e| CoreError::remote(format!("pin: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
@@ -1881,7 +1892,7 @@ impl Core {
|
||||
let mut stack = vec![ino];
|
||||
while let Some(dir) = stack.pop() {
|
||||
self.ensure_children(dir)
|
||||
.map_err(|e| CoreError::remote(format!("enumerate: {e:?}")))?;
|
||||
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
||||
let st = self.state.lock();
|
||||
if let Some(kids) = st.children.get(&dir) {
|
||||
for &k in kids {
|
||||
@@ -1961,7 +1972,7 @@ impl Core {
|
||||
};
|
||||
self.cache
|
||||
.remove_pin(&uid)
|
||||
.map_err(|e| CoreError::remote(format!("unpin: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "unpin"))?;
|
||||
// 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.
|
||||
@@ -1990,7 +2001,7 @@ impl Core {
|
||||
fn list_dir(&self, rel: &Path) -> CoreResult<Vec<DirEntry>> {
|
||||
let (ino, _uid) = self.resolve(rel)?;
|
||||
self.ensure_children(ino)
|
||||
.map_err(|e| CoreError::remote(format!("enumerate: {e:?}")))?;
|
||||
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
||||
// 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)> = {
|
||||
@@ -2037,7 +2048,7 @@ impl Core {
|
||||
let hits = self
|
||||
.db
|
||||
.search(query, limit)
|
||||
.map_err(|e| CoreError::remote(format!("search: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "search"))?;
|
||||
Ok(hits
|
||||
.into_iter()
|
||||
.map(|h| SearchHit {
|
||||
@@ -2059,7 +2070,7 @@ impl Core {
|
||||
let hits = self
|
||||
.db
|
||||
.search_local(query, limit)
|
||||
.map_err(|e| CoreError::remote(format!("local search: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "local search"))?;
|
||||
Ok(hits
|
||||
.into_iter()
|
||||
.map(|h| LocalHit {
|
||||
@@ -2101,7 +2112,10 @@ impl Core {
|
||||
let (ino, uid) = self.resolve(rel)?;
|
||||
let (name, mtime, size) = {
|
||||
let st = self.state.lock();
|
||||
let e = st.entries.get(&ino).ok_or_else(|| CoreError::not_found("node vanished"))?;
|
||||
let e = st
|
||||
.entries
|
||||
.get(&ino)
|
||||
.ok_or_else(|| CoreError::not_found("node vanished"))?;
|
||||
if !e.node.is_file() {
|
||||
return Err(CoreError::invalid("not a regular file"));
|
||||
}
|
||||
@@ -2116,7 +2130,7 @@ impl Core {
|
||||
}
|
||||
let bytes = self
|
||||
.download_file_tracked(&uid, &name, size)
|
||||
.map_err(|e| CoreError::remote(format!("download: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "download"))?;
|
||||
self.cache
|
||||
.store(&uid, mtime, size, &bytes)
|
||||
.map_err(|e| CoreError::internal(format!("cache store: {e}")))?;
|
||||
@@ -2145,7 +2159,7 @@ impl Core {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.rt
|
||||
.block_on(self.client.rename_node(&uid, new_name, None))
|
||||
.map_err(|e| CoreError::remote(format!("rename: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "rename"))?;
|
||||
self.state.lock().forget(&uid);
|
||||
self.invalidate_parent_listing(rel);
|
||||
Ok(new_name.to_string())
|
||||
@@ -2158,10 +2172,10 @@ impl Core {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
let (pino, new_parent_uid) = self
|
||||
.resolve_path(new_parent_rel)
|
||||
.map_err(|e| CoreError::remote(format!("resolve new parent: {e:?}")))?;
|
||||
.map_err(|e| self.errno_error(e, "resolve new parent"))?;
|
||||
self.rt
|
||||
.block_on(self.client.move_node(&uid, &new_parent_uid))
|
||||
.map_err(|e| CoreError::remote(format!("move: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "move"))?;
|
||||
let name = self
|
||||
.state
|
||||
.lock()
|
||||
@@ -2179,7 +2193,7 @@ impl Core {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.rt
|
||||
.block_on(self.client.trash_nodes(std::slice::from_ref(&uid)))
|
||||
.map_err(|e| CoreError::remote(format!("trash: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "trash"))?;
|
||||
let name = self
|
||||
.state
|
||||
.lock()
|
||||
@@ -2238,14 +2252,14 @@ impl Core {
|
||||
.client
|
||||
.enumerate_trash_node_uids()
|
||||
.await
|
||||
.map_err(|e| CoreError::remote(format!("enumerate trash: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate trash"))?;
|
||||
let nodes = if uids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.client
|
||||
.enumerate_nodes(&uids)
|
||||
.await
|
||||
.map_err(|e| CoreError::remote(format!("enumerate nodes: {e}")))?
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?
|
||||
};
|
||||
let items: Vec<StoredTrash> = nodes
|
||||
.into_iter()
|
||||
@@ -2327,13 +2341,13 @@ impl Core {
|
||||
let parents: Vec<NodeUid> = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(&parsed))
|
||||
.map_err(|e| CoreError::remote(format!("enumerate nodes: {e}")))?
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?
|
||||
.into_iter()
|
||||
.filter_map(|n| n.parent_uid)
|
||||
.collect();
|
||||
self.rt
|
||||
.block_on(self.client.restore_nodes(&parsed))
|
||||
.map_err(|e| CoreError::remote(format!("restore: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "restore"))?;
|
||||
{
|
||||
let mut st = self.state.lock();
|
||||
for parent in parents {
|
||||
@@ -2352,7 +2366,7 @@ impl Core {
|
||||
let parsed = Self::parse_uids(uids)?;
|
||||
self.rt
|
||||
.block_on(self.client.delete_nodes(&parsed))
|
||||
.map_err(|e| CoreError::remote(format!("delete: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "delete"))?;
|
||||
self.drop_local(&parsed);
|
||||
self.invalidate_trash();
|
||||
Ok(parsed.len())
|
||||
@@ -2365,10 +2379,10 @@ impl Core {
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_trash_node_uids())
|
||||
.map_err(|e| CoreError::remote(format!("enumerate trash: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate trash"))?;
|
||||
self.rt
|
||||
.block_on(self.client.empty_trash())
|
||||
.map_err(|e| CoreError::remote(format!("empty trash: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "empty trash"))?;
|
||||
self.drop_local(&uids);
|
||||
self.invalidate_trash();
|
||||
Ok(uids.len())
|
||||
@@ -2396,17 +2410,17 @@ impl Core {
|
||||
}
|
||||
let (pino, parent_uid) = self.resolve(parent_rel)?;
|
||||
self.ensure_children(pino)
|
||||
.map_err(|e| CoreError::remote(format!("enumerate: {e:?}")))?;
|
||||
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
||||
let new_uid = self
|
||||
.rt
|
||||
.block_on(
|
||||
self.client
|
||||
.create_folder(&parent_uid, name, Some(now_secs())),
|
||||
)
|
||||
.map_err(|e| CoreError::remote(format!("create folder: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "create folder"))?;
|
||||
let node = self
|
||||
.fetch_node(&new_uid)
|
||||
.map_err(|e| CoreError::remote(format!("fetch node: {e:?}")))?;
|
||||
.map_err(|e| self.errno_error(e, "fetch node"))?;
|
||||
let mut st = self.state.lock();
|
||||
let ino = st.intern(pino, node);
|
||||
if let Some(kids) = st.children.get_mut(&pino)
|
||||
@@ -2425,7 +2439,7 @@ impl Core {
|
||||
}
|
||||
let (pino, parent_uid) = self.resolve(parent_rel)?;
|
||||
self.ensure_children(pino)
|
||||
.map_err(|e| CoreError::remote(format!("enumerate: {e:?}")))?;
|
||||
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
||||
let guard = self
|
||||
.transfers
|
||||
.begin(name, "", TransferDirection::Upload, bytes.len() as u64);
|
||||
@@ -2442,11 +2456,11 @@ impl Core {
|
||||
None,
|
||||
false,
|
||||
))
|
||||
.map_err(|e| CoreError::remote(format!("upload: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "upload"))?;
|
||||
drop(guard);
|
||||
let node = self
|
||||
.fetch_node(&new_uid)
|
||||
.map_err(|e| CoreError::remote(format!("fetch node: {e:?}")))?;
|
||||
.map_err(|e| self.errno_error(e, "fetch node"))?;
|
||||
let mut st = self.state.lock();
|
||||
let ino = st.intern(pino, node);
|
||||
if let Some(kids) = st.children.get_mut(&pino)
|
||||
@@ -2468,7 +2482,7 @@ impl Core {
|
||||
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| CoreError::remote(format!("enumerate: {e:?}")))?;
|
||||
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
||||
|
||||
// Phase 1 (sequential): build the remote folder skeleton and collect the
|
||||
// flat list of files to upload. Folders must exist before their children,
|
||||
@@ -2563,7 +2577,7 @@ impl Core {
|
||||
return Err(CoreError::invalid(format!("invalid folder name: {name:?}")));
|
||||
}
|
||||
self.ensure_children(pino)
|
||||
.map_err(|e| CoreError::remote(format!("enumerate: {e:?}")))?;
|
||||
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
||||
{
|
||||
let st = self.state.lock();
|
||||
if let Some(kids) = st.children.get(&pino) {
|
||||
@@ -2583,10 +2597,10 @@ impl Core {
|
||||
self.client
|
||||
.create_folder(parent_uid, name, Some(now_secs())),
|
||||
)
|
||||
.map_err(|e| CoreError::remote(format!("create folder {name}: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, &format!("create folder {name}")))?;
|
||||
let node = self
|
||||
.fetch_node(&new_uid)
|
||||
.map_err(|e| CoreError::remote(format!("fetch node: {e:?}")))?;
|
||||
.map_err(|e| self.errno_error(e, "fetch node"))?;
|
||||
let mut st = self.state.lock();
|
||||
let ino = st.intern(pino, node);
|
||||
if let Some(kids) = st.children.get_mut(&pino)
|
||||
@@ -2612,8 +2626,8 @@ impl Core {
|
||||
folders: &mut usize,
|
||||
job: &JobGuard,
|
||||
) -> CoreResult<()> {
|
||||
let meta =
|
||||
std::fs::symlink_metadata(src).map_err(|e| CoreError::internal(format!("stat {}: {e}", src.display())))?;
|
||||
let meta = 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())
|
||||
@@ -2655,7 +2669,6 @@ impl Core {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
// ---- activity log -----------------------------------------------------
|
||||
|
||||
/// Append one entry to the activity log. Callable from any thread (the sync
|
||||
@@ -4378,7 +4391,6 @@ fn scan_local_once(
|
||||
indexing.store(false, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
|
||||
/// Why a [`mount`] call returned. Lets the daemon decide whether to exit (clean
|
||||
/// shutdown) or remount (the mount went away under it).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
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 pdfs_core::{CoreError, CoreResult};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use proton_drive_rs::{NodeKind, ThumbnailType};
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use proton_drive_rs::{NodeKind, ThumbnailType};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::{
|
||||
@@ -275,7 +275,7 @@ impl Core {
|
||||
if photos
|
||||
.get_photos_root()
|
||||
.await
|
||||
.map_err(|e| CoreError::remote(format!("photos root: {e}")))?
|
||||
.map_err(|e| CoreError::from_api(&e, "photos root"))?
|
||||
.is_none()
|
||||
{
|
||||
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 0);
|
||||
@@ -285,7 +285,7 @@ impl Core {
|
||||
let items = photos
|
||||
.enumerate_timeline()
|
||||
.await
|
||||
.map_err(|e| CoreError::remote(format!("timeline: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "timeline"))?;
|
||||
|
||||
// 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
|
||||
@@ -346,7 +346,7 @@ impl Core {
|
||||
let node = self
|
||||
.rt
|
||||
.block_on(photos.get_node(uid))
|
||||
.map_err(|e| CoreError::remote(format!("photo node: {e}")))?
|
||||
.map_err(|e| CoreError::from_api(&e, "photo node"))?
|
||||
.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) {
|
||||
@@ -354,11 +354,10 @@ impl Core {
|
||||
}
|
||||
let bytes = self
|
||||
.download_photo_tracked(&photos, uid, &node.name, size)
|
||||
.map_err(|e| CoreError::remote(format!("download photo: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "download photo"))?;
|
||||
self.cache
|
||||
.store(uid, mtime, size, &bytes)
|
||||
.map_err(|e| CoreError::internal(format!("cache store: {e}")))?;
|
||||
Ok(self.cache.content_path(uid))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,12 +8,12 @@
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use pdfs_core::control::{
|
||||
BookmarkInfo, DirEntry, InvitationInfo, PublicLinkInfo, ShareEntry, ShareEntryKind, SharedItem,
|
||||
};
|
||||
use proton_drive_rs::{MemberRole, NodeKind};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use proton_drive_rs::{MemberRole, NodeKind};
|
||||
|
||||
use super::{Core, ROOT_INO, parse_uid, public_link_info, role_from_str, role_to_str};
|
||||
|
||||
@@ -35,18 +35,21 @@ impl Core {
|
||||
emails.iter().map(|e| (e.clone(), role)).collect();
|
||||
self.rt
|
||||
.block_on(self.client.invite_users(&uid, &invitees, message))
|
||||
.map_err(|e| CoreError::remote(format!("share: {e}")))
|
||||
.map_err(|e| CoreError::from_api(&e, "share"))
|
||||
}
|
||||
|
||||
/// List the members, pending invitations and public link of the node at `rel`.
|
||||
pub(crate) fn list_share(&self, rel: &Path) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
||||
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| CoreError::remote(format!("list members: {e}")))?
|
||||
.map_err(|e| CoreError::from_api(&e, "list members"))?
|
||||
{
|
||||
entries.push(ShareEntry {
|
||||
id: m.membership_id.to_string(),
|
||||
@@ -58,7 +61,7 @@ impl Core {
|
||||
for inv in self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(&uid))
|
||||
.map_err(|e| CoreError::remote(format!("list invitations: {e}")))?
|
||||
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
|
||||
{
|
||||
entries.push(ShareEntry {
|
||||
id: inv.invitation_id,
|
||||
@@ -70,7 +73,7 @@ impl Core {
|
||||
for ext in self
|
||||
.rt
|
||||
.block_on(self.client.list_external_invitations(&uid))
|
||||
.map_err(|e| CoreError::remote(format!("list external invitations: {e}")))?
|
||||
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?
|
||||
{
|
||||
entries.push(ShareEntry {
|
||||
id: ext.invitation_id,
|
||||
@@ -83,7 +86,7 @@ impl Core {
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(self.client.get_public_link(&uid))
|
||||
.map_err(|e| CoreError::remote(format!("get public link: {e}")))?
|
||||
.map_err(|e| CoreError::from_api(&e, "get public link"))?
|
||||
.map(public_link_info);
|
||||
|
||||
Ok((entries, link))
|
||||
@@ -105,72 +108,77 @@ impl Core {
|
||||
let member = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_members(&uid))
|
||||
.map_err(|e| CoreError::remote(format!("list members: {e}")))?
|
||||
.map_err(|e| CoreError::from_api(&e, "list members"))?
|
||||
.into_iter()
|
||||
.find(|m| m.membership_id.to_string() == id)
|
||||
.ok_or_else(|| CoreError::not_found("member not found"))?;
|
||||
self.rt
|
||||
.block_on(self.client.update_member_role(&member, role))
|
||||
.map_err(|e| CoreError::remote(format!("update role: {e}")))
|
||||
.map_err(|e| CoreError::from_api(&e, "update role"))
|
||||
}
|
||||
ShareEntryKind::ProtonInvite => {
|
||||
let inv = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(&uid))
|
||||
.map_err(|e| CoreError::remote(format!("list invitations: {e}")))?
|
||||
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
|
||||
.into_iter()
|
||||
.find(|i| i.invitation_id == id)
|
||||
.ok_or_else(|| CoreError::not_found("invitation not found"))?;
|
||||
self.rt
|
||||
.block_on(self.client.update_invitation_role(&inv, role))
|
||||
.map_err(|e| CoreError::remote(format!("update role: {e}")))
|
||||
}
|
||||
ShareEntryKind::ExternalInvite => {
|
||||
Err(CoreError::invalid("an external invitation's role cannot be changed"))
|
||||
.map_err(|e| CoreError::from_api(&e, "update role"))
|
||||
}
|
||||
ShareEntryKind::ExternalInvite => 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) -> CoreResult<()> {
|
||||
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| CoreError::remote(format!("list members: {e}")))?
|
||||
.map_err(|e| CoreError::from_api(&e, "list members"))?
|
||||
.into_iter()
|
||||
.find(|m| m.membership_id.to_string() == id)
|
||||
.ok_or_else(|| CoreError::not_found("member not found"))?;
|
||||
self.rt
|
||||
.block_on(self.client.remove_member(&member))
|
||||
.map_err(|e| CoreError::remote(format!("remove member: {e}")))
|
||||
.map_err(|e| CoreError::from_api(&e, "remove member"))
|
||||
}
|
||||
ShareEntryKind::ProtonInvite => {
|
||||
let inv = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(&uid))
|
||||
.map_err(|e| CoreError::remote(format!("list invitations: {e}")))?
|
||||
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
|
||||
.into_iter()
|
||||
.find(|i| i.invitation_id == id)
|
||||
.ok_or_else(|| CoreError::not_found("invitation not found"))?;
|
||||
self.rt
|
||||
.block_on(self.client.delete_invitation(&inv))
|
||||
.map_err(|e| CoreError::remote(format!("revoke invitation: {e}")))
|
||||
.map_err(|e| CoreError::from_api(&e, "revoke invitation"))
|
||||
}
|
||||
ShareEntryKind::ExternalInvite => {
|
||||
let ext = self
|
||||
.rt
|
||||
.block_on(self.client.list_external_invitations(&uid))
|
||||
.map_err(|e| CoreError::remote(format!("list external invitations: {e}")))?
|
||||
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?
|
||||
.into_iter()
|
||||
.find(|i| i.invitation_id == id)
|
||||
.ok_or_else(|| CoreError::not_found("external invitation not found"))?;
|
||||
self.rt
|
||||
.block_on(self.client.delete_external_invitation(&ext))
|
||||
.map_err(|e| CoreError::remote(format!("revoke external invitation: {e}")))
|
||||
.map_err(|e| CoreError::from_api(&e, "revoke external invitation"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -191,7 +199,7 @@ impl Core {
|
||||
self.client
|
||||
.create_public_link(&uid, role, password, expires),
|
||||
)
|
||||
.map_err(|e| CoreError::remote(format!("create public link: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "create public link"))?;
|
||||
Ok(public_link_info(link))
|
||||
}
|
||||
|
||||
@@ -201,12 +209,12 @@ impl Core {
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(self.client.get_public_link(&uid))
|
||||
.map_err(|e| CoreError::remote(format!("get public link: {e}")))?
|
||||
.map_err(|e| CoreError::from_api(&e, "get public link"))?
|
||||
.filter(|l| l.public_link_id == id)
|
||||
.ok_or_else(|| CoreError::not_found("public link not found"))?;
|
||||
self.rt
|
||||
.block_on(self.client.remove_public_link(&link))
|
||||
.map_err(|e| CoreError::remote(format!("remove public link: {e}")))
|
||||
.map_err(|e| CoreError::from_api(&e, "remove public link"))
|
||||
}
|
||||
|
||||
// ---- shared with me ---------------------------------------------------
|
||||
@@ -216,14 +224,14 @@ impl Core {
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_shared_with_me_node_uids())
|
||||
.map_err(|e| CoreError::remote(format!("enumerate shared: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate shared"))?;
|
||||
if uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::remote(format!("enumerate nodes: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
.map(|n| {
|
||||
@@ -252,10 +260,11 @@ impl Core {
|
||||
|
||||
/// Leave a shared node by its uid.
|
||||
pub(crate) fn leave_shared(&self, uid: &str) -> CoreResult<()> {
|
||||
let uid = parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?;
|
||||
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| CoreError::remote(format!("leave shared: {e}")))
|
||||
.map_err(|e| CoreError::from_api(&e, "leave shared"))
|
||||
}
|
||||
|
||||
// ---- incoming invitations ---------------------------------------------
|
||||
@@ -265,7 +274,7 @@ impl Core {
|
||||
let invitations = self
|
||||
.rt
|
||||
.block_on(self.client.list_incoming_invitations())
|
||||
.map_err(|e| CoreError::remote(format!("list invitations: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "list invitations"))?;
|
||||
Ok(invitations
|
||||
.into_iter()
|
||||
.map(|i| InvitationInfo {
|
||||
@@ -281,14 +290,14 @@ impl Core {
|
||||
pub(crate) fn accept_invitation(&self, id: &str) -> CoreResult<()> {
|
||||
self.rt
|
||||
.block_on(self.client.accept_invitation(id))
|
||||
.map_err(|e| CoreError::remote(format!("accept invitation: {e}")))
|
||||
.map_err(|e| CoreError::from_api(&e, "accept invitation"))
|
||||
}
|
||||
|
||||
/// Reject an invitation addressed to me by its id.
|
||||
pub(crate) fn reject_invitation(&self, id: &str) -> CoreResult<()> {
|
||||
self.rt
|
||||
.block_on(self.client.reject_invitation(id))
|
||||
.map_err(|e| CoreError::remote(format!("reject invitation: {e}")))
|
||||
.map_err(|e| CoreError::from_api(&e, "reject invitation"))
|
||||
}
|
||||
|
||||
// ---- bookmarks --------------------------------------------------------
|
||||
@@ -298,7 +307,7 @@ impl Core {
|
||||
let bookmarks = self
|
||||
.rt
|
||||
.block_on(self.client.list_bookmarks())
|
||||
.map_err(|e| CoreError::remote(format!("list bookmarks: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "list bookmarks"))?;
|
||||
Ok(bookmarks
|
||||
.into_iter()
|
||||
.map(|b| BookmarkInfo {
|
||||
@@ -314,14 +323,14 @@ impl Core {
|
||||
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| CoreError::remote(format!("create bookmark: {e}")))
|
||||
.map_err(|e| CoreError::from_api(&e, "create bookmark"))
|
||||
}
|
||||
|
||||
/// Remove a saved bookmark by its token.
|
||||
pub(crate) fn delete_bookmark(&self, token: &str) -> CoreResult<()> {
|
||||
self.rt
|
||||
.block_on(self.client.delete_bookmark(token))
|
||||
.map_err(|e| CoreError::remote(format!("delete bookmark: {e}")))
|
||||
.map_err(|e| CoreError::from_api(&e, "delete bookmark"))
|
||||
}
|
||||
|
||||
// ---- shared by me -----------------------------------------------------
|
||||
@@ -335,14 +344,14 @@ impl Core {
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_shared_by_me_node_uids())
|
||||
.map_err(|e| CoreError::remote(format!("enumerate shared-by-me: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate shared-by-me"))?;
|
||||
if uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::remote(format!("enumerate nodes: {e}")))?;
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
||||
let mut items = Vec::with_capacity(nodes.len());
|
||||
for n in nodes {
|
||||
let uid = n.uid.clone();
|
||||
|
||||
@@ -9,12 +9,10 @@ use std::sync::Arc;
|
||||
|
||||
use pdfs_core::cache::StagedWrite;
|
||||
use pdfs_core::db::Db;
|
||||
use proton_drive_rs::{Node, NodeKind};
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use proton_drive_rs::{Node, NodeKind};
|
||||
use tracing::warn;
|
||||
|
||||
|
||||
|
||||
/// A node known to the filesystem, addressed by its kernel inode.
|
||||
pub(crate) struct Entry {
|
||||
pub(crate) uid: NodeUid,
|
||||
@@ -222,7 +220,13 @@ impl State {
|
||||
/// server did. A queued rename cannot: re-enumerating needs the network, and
|
||||
/// the server has not been told yet in any case — so this *is* the tree's
|
||||
/// new truth until the op drains (offline.md Phase 3b).
|
||||
pub(crate) fn rename_in_place(&mut self, ino: u64, new_parent: u64, new_parent_uid: &NodeUid, name: &str) {
|
||||
pub(crate) fn rename_in_place(
|
||||
&mut self,
|
||||
ino: u64,
|
||||
new_parent: u64,
|
||||
new_parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
) {
|
||||
let Some(entry) = self.entries.get_mut(&ino) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
//! action, so a file just uploaded or downloaded reads as "unchanged" on the
|
||||
//! next pass and never ping-pongs.
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
//!
|
||||
//! [`Request::GetQueueStatus`]: pdfs_core::control::Request::GetQueueStatus
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use pdfs_core::control::{JobItem, TransferDirection, TransferItem};
|
||||
|
||||
@@ -39,11 +39,10 @@ use pdfs_core::auth;
|
||||
use pdfs_core::config::AppDirs;
|
||||
|
||||
use pdfs_core::control::{
|
||||
ErrorKind,
|
||||
ActivityEntry, ActivityKind, BookmarkInfo, DeviceInfo, DirEntry, InvitationInfo, JobItem,
|
||||
PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response, SearchHit, ShareEntry,
|
||||
ShareEntryKind, SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection,
|
||||
TransferItem, pending_summary, send,
|
||||
ActivityEntry, ActivityKind, BookmarkInfo, DeviceInfo, DirEntry, ErrorKind, InvitationInfo,
|
||||
JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response, SearchHit,
|
||||
ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo, SyncPhase, SyncProgress,
|
||||
TransferDirection, TransferItem, pending_summary, send,
|
||||
};
|
||||
|
||||
use pdfs_core::service;
|
||||
@@ -134,7 +133,8 @@ impl Ui {
|
||||
/// The aspect ratio (w/h) to lay `uid`'s tile out at: the real one once its
|
||||
/// thumbnail has been seen, otherwise [`RATIO_UNKNOWN`].
|
||||
fn ratio(&self, uid: &str) -> f64 {
|
||||
self.gallery.photo_ratio
|
||||
self.gallery
|
||||
.photo_ratio
|
||||
.borrow()
|
||||
.get(uid)
|
||||
.copied()
|
||||
@@ -809,6 +809,7 @@ fn error_headline(kind: ErrorKind, fallback: &str) -> &str {
|
||||
ErrorKind::NotFound => "That's not there any more",
|
||||
ErrorKind::Denied => "You don't have access to that",
|
||||
ErrorKind::Conflict => "Something changed this first",
|
||||
ErrorKind::Quota => "Your Proton Drive is full",
|
||||
ErrorKind::Invalid | ErrorKind::Remote | ErrorKind::Internal => fallback,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,13 @@ pub(crate) fn wire_activity(ui: &Rc<Ui>, retry: >k4::Button) {
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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));
|
||||
|
||||
@@ -609,7 +609,11 @@ pub(crate) fn stream_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
|
||||
let abs = mountpoint.join(&rel);
|
||||
let Some(path) = abs.to_str() else {
|
||||
toast_error(ui, "Couldn't play video", "The file path isn't valid UTF-8.");
|
||||
toast_error(
|
||||
ui,
|
||||
"Couldn't play video",
|
||||
"The file path isn't valid UTF-8.",
|
||||
);
|
||||
return;
|
||||
};
|
||||
toast(ui, &format!("Streaming “{}”…", entry.name));
|
||||
@@ -744,7 +748,8 @@ pub(crate) fn repaint_crumb(ui: &Rc<Ui>, path: &str) {
|
||||
} else {
|
||||
path.split('/').collect()
|
||||
};
|
||||
ui.browser.crumb
|
||||
ui.browser
|
||||
.crumb
|
||||
.append(&crumb_node(ui, "Proton Drive", "", segments.is_empty()));
|
||||
let mut acc = String::new();
|
||||
for (i, seg) in segments.iter().enumerate() {
|
||||
@@ -1071,7 +1076,9 @@ pub(crate) fn start_upload(ui: &Rc<Ui>, sources: Vec<String>) {
|
||||
};
|
||||
toast(&ui, &what);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, "Couldn't upload", &message, kind),
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't upload", &message, kind)
|
||||
}
|
||||
_ => toast_error(&ui, "Couldn't upload", "The mount service didn't respond."),
|
||||
}
|
||||
});
|
||||
@@ -1237,7 +1244,11 @@ pub(crate) fn load_browser(ui: &Rc<Ui>) {
|
||||
match result {
|
||||
Ok(Ok(Response::Entries { entries })) => repaint_browser(&ui, &entries),
|
||||
Ok(Ok(Response::Error { message, kind })) => browser_failed(&ui, &message, kind),
|
||||
Ok(Ok(_)) => browser_failed(&ui, "Unexpected reply from the mount service.", ErrorKind::Internal),
|
||||
Ok(Ok(_)) => browser_failed(
|
||||
&ui,
|
||||
"Unexpected reply from the mount service.",
|
||||
ErrorKind::Internal,
|
||||
),
|
||||
Ok(Err(_)) | Err(_) => browser_unreachable(&ui),
|
||||
}
|
||||
});
|
||||
@@ -1374,7 +1385,11 @@ pub(crate) fn run_search(ui: &Rc<Ui>, query: &str) {
|
||||
match result {
|
||||
Ok(Ok(Response::SearchResults { hits })) => repaint_search(&ui, &hits),
|
||||
Ok(Ok(Response::Error { message, kind })) => browser_failed(&ui, &message, kind),
|
||||
Ok(Ok(_)) => browser_failed(&ui, "Unexpected reply from the mount service.", ErrorKind::Internal),
|
||||
Ok(Ok(_)) => browser_failed(
|
||||
&ui,
|
||||
"Unexpected reply from the mount service.",
|
||||
ErrorKind::Internal,
|
||||
),
|
||||
Ok(Err(_)) | Err(_) => browser_unreachable(&ui),
|
||||
}
|
||||
});
|
||||
|
||||
@@ -699,7 +699,12 @@ pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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();
|
||||
|
||||
@@ -82,10 +82,12 @@ 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
|
||||
ui.login
|
||||
.email
|
||||
.connect_entry_activated(move |_| btn.emit_clicked());
|
||||
let btn = ui.login.login_button.clone();
|
||||
ui.login.password
|
||||
ui.login
|
||||
.password
|
||||
.connect_entry_activated(move |_| btn.emit_clicked());
|
||||
|
||||
let ui = ui.clone();
|
||||
@@ -94,7 +96,9 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
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.");
|
||||
ui.login
|
||||
.login_status
|
||||
.set_text("Enter your email and password.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -129,7 +133,10 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
service::enable_start();
|
||||
refresh(&ui);
|
||||
}
|
||||
Err(e) => ui.login.login_status.set_text(&format!("Sign-in failed: {e}")),
|
||||
Err(e) => ui
|
||||
.login
|
||||
.login_status
|
||||
.set_text(&format!("Sign-in failed: {e}")),
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -322,7 +322,10 @@ pub(crate) fn navigate_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, delta: i32) {
|
||||
let renderable = |idx: u32| -> Option<PhotoItem> {
|
||||
model
|
||||
.item(idx)
|
||||
.and_then(|obj| obj.downcast_ref::<BoxedAnyObject>().map(|b| b.borrow::<PhotoItem>().clone()))
|
||||
.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)> {
|
||||
@@ -564,7 +567,8 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
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
|
||||
.gallery
|
||||
.model
|
||||
.item(initial_idx)
|
||||
.and_downcast::<BoxedAnyObject>()
|
||||
.map_or(0, |boxed| boxed.borrow::<PhotoItem>().capture_time);
|
||||
|
||||
@@ -356,8 +356,18 @@ pub(crate) fn month_range(year: i32, month: i32) -> Option<(i64, i64)> {
|
||||
|
||||
/// English month names, indexed 1..=12.
|
||||
pub(crate) const MONTH_NAMES: [&str; 12] = [
|
||||
"January", "February", "March", "April", "May", "June", "July", "August", "September",
|
||||
"October", "November", "December",
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
|
||||
/// Rebuild the date-jump dropdown for the active kind: ask the daemon which
|
||||
@@ -380,7 +390,10 @@ pub(crate) fn refresh_photo_months(ui: &Rc<Ui>) {
|
||||
let mut labels = vec!["All dates".to_string()];
|
||||
let mut ranges: Vec<Option<(i64, i64)>> = vec![None];
|
||||
for m in months {
|
||||
let name = MONTH_NAMES.get((m.month - 1) as usize).copied().unwrap_or("?");
|
||||
let name = MONTH_NAMES
|
||||
.get((m.month - 1) as usize)
|
||||
.copied()
|
||||
.unwrap_or("?");
|
||||
labels.push(format!("{name} {} ({})", m.year, m.count));
|
||||
ranges.push(month_range(m.year, m.month));
|
||||
}
|
||||
@@ -389,7 +402,8 @@ pub(crate) fn refresh_photo_months(ui: &Rc<Ui>) {
|
||||
// Repopulating resets the selection to 0, which would otherwise fire the
|
||||
// handler and reload; suppress that — the caller is already reloading.
|
||||
ui.gallery.date_suppress.set(true);
|
||||
ui.gallery.dates
|
||||
ui.gallery
|
||||
.dates
|
||||
.set_model(Some(>k4::StringList::new(&label_refs)));
|
||||
ui.gallery.dates.set_selected(0);
|
||||
*ui.gallery.date_ranges.borrow_mut() = ranges;
|
||||
@@ -444,7 +458,8 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::Sc
|
||||
// re-justify it in place, without rebuilding the ListStore (which would
|
||||
// yank the scroll position back to the top).
|
||||
ui_bind
|
||||
.gallery.bound
|
||||
.gallery
|
||||
.bound
|
||||
.borrow_mut()
|
||||
.insert(item.position(), section);
|
||||
});
|
||||
@@ -456,7 +471,8 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::Sc
|
||||
factory.connect_unbind(move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
ui_unbind
|
||||
.gallery.bound
|
||||
.gallery
|
||||
.bound
|
||||
.borrow_mut()
|
||||
.remove(&item.position());
|
||||
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>() {
|
||||
@@ -562,7 +578,8 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::Sc
|
||||
return;
|
||||
}
|
||||
let range = ui_dates
|
||||
.gallery.date_ranges
|
||||
.gallery
|
||||
.date_ranges
|
||||
.borrow()
|
||||
.get(dd.selected() as usize)
|
||||
.copied()
|
||||
@@ -742,7 +759,12 @@ pub(crate) fn plan_rows(ratios: &[f64], target: f64, width: f64) -> Vec<Vec<(i32
|
||||
/// Size one row's tiles at `height`. A `justified` row is nudged so its widths
|
||||
/// plus gaps hit `avail` exactly — rounding each width independently leaves a
|
||||
/// few px of ragged right edge, so the last tile absorbs the remainder.
|
||||
pub(crate) fn size_row(ratios: &[f64], height: f64, avail: f64, justified: bool) -> Vec<(i32, i32)> {
|
||||
pub(crate) fn size_row(
|
||||
ratios: &[f64],
|
||||
height: f64,
|
||||
avail: f64,
|
||||
justified: bool,
|
||||
) -> Vec<(i32, i32)> {
|
||||
let height = height.round().max(1.0);
|
||||
let mut sizes: Vec<(i32, i32)> = ratios
|
||||
.iter()
|
||||
@@ -896,13 +918,15 @@ pub(crate) fn want_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: >k4::Picture
|
||||
return;
|
||||
}
|
||||
|
||||
ui.gallery.thumb_wanted
|
||||
ui.gallery
|
||||
.thumb_wanted
|
||||
.borrow_mut()
|
||||
.insert(photo.uid.clone(), picture.clone());
|
||||
|
||||
match photo.thumb_path.as_deref() {
|
||||
Some(path) => {
|
||||
ui.gallery.decode_queue
|
||||
ui.gallery
|
||||
.decode_queue
|
||||
.borrow_mut()
|
||||
.push_back((photo.uid.clone(), path.to_string()));
|
||||
schedule_decode(ui);
|
||||
@@ -1082,7 +1106,8 @@ pub(crate) fn schedule_relayout(ui: &Rc<Ui>) {
|
||||
/// ListView binds them.
|
||||
pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
|
||||
let bound: Vec<(u32, gtk4::Box)> = ui
|
||||
.gallery.bound
|
||||
.gallery
|
||||
.bound
|
||||
.borrow()
|
||||
.iter()
|
||||
.map(|(pos, section)| (*pos, section.clone()))
|
||||
@@ -1444,9 +1469,11 @@ pub(crate) fn play_video(ui: &Rc<Ui>, uid: String) {
|
||||
"Couldn't open this video",
|
||||
"Unexpected reply from the mount service.",
|
||||
),
|
||||
Ok(Err(_)) | Err(_) => {
|
||||
toast_error(&ui, "Couldn't open this video", "Couldn't reach Proton Drive.")
|
||||
}
|
||||
Ok(Err(_)) | Err(_) => toast_error(
|
||||
&ui,
|
||||
"Couldn't open this video",
|
||||
"Couldn't reach Proton Drive.",
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -472,7 +472,12 @@ pub(crate) fn prompt_add_bookmark(ui: &Rc<Ui>) {
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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();
|
||||
|
||||
@@ -94,7 +94,13 @@ pub(crate) fn wire_shared_by_me(ui: &Rc<Ui>, retry: >k4::Button) {
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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));
|
||||
|
||||
@@ -259,14 +259,20 @@ pub(crate) const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
|
||||
/// 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) {
|
||||
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
|
||||
ui.status
|
||||
.budget_row
|
||||
.set_value(config.resolved_cache_budget() as f64 / GIB);
|
||||
ui.status.mountpoint_row
|
||||
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);
|
||||
@@ -337,7 +343,12 @@ pub(crate) fn wire_settings(ui: &Rc<Ui>, purge_button: >k4::Button, mountpoint
|
||||
/// 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) {
|
||||
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();
|
||||
@@ -586,7 +597,8 @@ pub(crate) fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem], jobs: &[Job
|
||||
.child(&row_box)
|
||||
.build();
|
||||
ui.status.transfers_group.add(&row);
|
||||
ui.status.transfer_rows
|
||||
ui.status
|
||||
.transfer_rows
|
||||
.borrow_mut()
|
||||
.push(TransferRow { row, label, bar });
|
||||
}
|
||||
@@ -742,7 +754,10 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
||||
.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 });
|
||||
ui.status
|
||||
.pin_rows
|
||||
.borrow_mut()
|
||||
.push(PinRow { row, unpin: None });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -147,7 +147,10 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
/// 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()] {
|
||||
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()) {
|
||||
@@ -160,10 +163,12 @@ pub(crate) fn wire_details(ui: &Rc<Ui>) {
|
||||
let ui_close = ui.clone();
|
||||
ui.details.details.close_button.connect_clicked(move |_| {
|
||||
ui_close
|
||||
.details.grid_selection
|
||||
.details
|
||||
.grid_selection
|
||||
.set_selected(gtk4::INVALID_LIST_POSITION);
|
||||
ui_close
|
||||
.details.list_selection
|
||||
.details
|
||||
.list_selection
|
||||
.set_selected(gtk4::INVALID_LIST_POSITION);
|
||||
hide_details(&ui_close);
|
||||
});
|
||||
@@ -193,7 +198,10 @@ pub(crate) fn wire_details(ui: &Rc<Ui>) {
|
||||
}
|
||||
});
|
||||
let ui_pin = ui.clone();
|
||||
ui.details.details.pin_row.connect_active_notify(move |row| {
|
||||
ui.details
|
||||
.details
|
||||
.pin_row
|
||||
.connect_active_notify(move |row| {
|
||||
if ui_pin.details.details_suppress.get() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -400,7 +400,11 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
|
||||
/// 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>) {
|
||||
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 {
|
||||
|
||||
@@ -840,9 +840,7 @@ impl Ui {
|
||||
// so it lands on the final list, not a half-populated one.
|
||||
if self.open_pending.get() && self.pending.get() == 0 {
|
||||
self.open_pending.set(false);
|
||||
if let (Some(index), Some(window)) =
|
||||
(self.cursor.get(), self.window.borrow().clone())
|
||||
{
|
||||
if let (Some(index), Some(window)) = (self.cursor.get(), self.window.borrow().clone()) {
|
||||
self.open(index, &window);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user