fixed some bugs

This commit is contained in:
nvrl committed 2026-07-18 19:24:11 +02:00
1 parent 7766628098
commit a90cf83ff5
39 files changed
+1573 -1285

No files matched your search

+5 -3
View File
@@ -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(),
}
}
+4
View File
@@ -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]
-1
View File
@@ -63,5 +63,4 @@ impl Db {
}
Ok(out)
}
}
-1
View File
@@ -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.
}
-1
View File
@@ -44,5 +44,4 @@ impl Db {
)?;
Ok(())
}
}
-1
View File
@@ -160,4 +160,3 @@ pub(super) fn local_hit(row: &rusqlite::Row) -> rusqlite::Result<LocalFileHit> {
mtime: row.get(4)?,
})
}
-3
View File
@@ -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;
";
+2 -3
View File
@@ -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};
-2
View File
@@ -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 {
+3 -4
View File
@@ -317,9 +317,9 @@ impl Db {
Ok(PendingCounts { uploads, changes })
}
/// Nothing bearing this volume may be handed to the API — it would 404. The
/// drain replaces it with the uid the server assigns.
pub const LOCAL_VOLUME: &str = "local";
/// Nothing bearing this volume may be handed to the API — it would 404. The
/// drain replaces it with the uid the server assigns.
pub const LOCAL_VOLUME: &str = "local";
/// Drop a queued op, once its upload has actually landed.
pub fn delete_op(&self, id: i64) -> Result<()> {
@@ -340,5 +340,4 @@ pub const LOCAL_VOLUME: &str = "local";
)?;
Ok(())
}
}
+16 -13
View File
@@ -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,18 +62,17 @@ 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
.get(uid)
.cloned()
.unwrap_or((None, THUMB_UNKNOWN, None));
let (ratio, thumb_state, learned_media) =
learned
.get(uid)
.cloned()
.unwrap_or((None, THUMB_UNKNOWN, None));
let media_type = media_type.clone().or(learned_media);
// 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)
}
}
-3
View File
@@ -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) ----
}
-1
View File
@@ -94,5 +94,4 @@ impl Db {
conn.execute("DELETE FROM sync_state WHERE key = ?1", params![key])?;
Ok(())
}
}
-1
View File
@@ -222,5 +222,4 @@ impl Db {
)?;
Ok(())
}
}
File diff suppressed because it is too large. Load diff
-1
View File
@@ -61,5 +61,4 @@ impl Db {
}
// ---- device sync (devices.md) -----------------------------------------
}
+146 -1
View File
@@ -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);
}
}
+24 -25
View File
@@ -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,30 +125,24 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
limit,
kind,
range,
}) => {
match core.photos_timeline(offset, limit, kind, range) {
Ok(Some(items)) => CtlResponse::Photos {
available: true,
items,
counts: core.db.photos_counts().ok(),
},
Ok(None) => CtlResponse::Photos {
available: false,
items: Vec::new(),
counts: None,
},
Err(e) => CtlResponse::error(e),
}
}
}) => match core.photos_timeline(offset, limit, kind, range) {
Ok(Some(items)) => CtlResponse::Photos {
available: true,
items,
counts: core.db.photos_counts().ok(),
},
Ok(None) => CtlResponse::Photos {
available: false,
items: Vec::new(),
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 {
+57 -35
View File
@@ -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 {
}
}
}
}
+8 -5
View File
@@ -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);
}
}
+85 -73
View File
@@ -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.
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:?}")),
}
})
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!("{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!("{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)]
+6 -7
View File
@@ -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))
}
}
+46 -37
View File
@@ -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();
+8 -4
View File
@@ -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;
};
+1 -1
View File
@@ -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};
+2 -2
View File
@@ -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};
+7 -6
View File
@@ -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,
}
}
+7 -1
View File
@@ -96,7 +96,13 @@ pub(crate) fn wire_activity(ui: &Rc<Ui>, retry: &gtk4::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));
+20 -5
View File
@@ -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),
}
});
+6 -1
View File
@@ -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();
+11 -4
View File
@@ -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);
+41 -14
View File
@@ -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(&gtk4::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: &gtk4::ListView, scroll: &gtk4::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: &gtk4::ListView, scroll: &gtk4::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: &gtk4::ListView, scroll: &gtk4::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: &gtk4::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.",
),
}
});
}
+6 -1
View File
@@ -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: &gtk4::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));
+21 -6
View File
@@ -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: &gtk4::Button, mountpoint_button: &gtk4::Button) {
pub(crate) fn wire_settings(
ui: &Rc<Ui>,
purge_button: &gtk4::Button,
mountpoint_button: &gtk4::Button,
) {
let config = ui.dirs.load_config();
// Populate from persisted config + the systemd unit state, suppressed.
ui.status.settings_suppress.set(true);
ui.status.budget_row
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: &gtk4::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;
}
+24 -16
View File
@@ -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,19 +198,22 @@ pub(crate) fn wire_details(ui: &Rc<Ui>) {
}
});
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);
}
});
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`.
@@ -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 {
+1 -3
View File
@@ -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);
}
}