persistent entity cache, streaming batche

This commit is contained in:
nvrl committed 2026-08-10 15:22:04 +02:00
1 parent 2336ad9e87
commit a5e03ad8da
33 files changed
+1936 -65

No files matched your search

+136 -3
View File
@@ -144,6 +144,17 @@ enum Command {
/// Skip this many photos from the start of the timeline.
#[arg(long, default_value_t = 0)]
offset: usize,
/// List only favourited photos.
#[arg(long)]
favorites: bool,
},
/// Add or remove a photo's favourite mark.
Favorite {
/// Photo node uid in `volume~link` form (from `pdfs photos`).
uid: String,
/// Remove the mark instead of adding it.
#[arg(long)]
remove: bool,
},
/// List the photo albums (including ones shared with this account).
Albums,
@@ -286,6 +297,11 @@ enum Command {
/// Entry kind: member, proton or external.
kind: ShareKindArg,
},
/// Inspect and restore a file's earlier versions.
Versions {
#[command(subcommand)]
action: VersionCmd,
},
/// Manage a node's public link.
PublicLink {
#[command(subcommand)]
@@ -380,6 +396,43 @@ impl ShareKindArg {
}
}
/// `pdfs versions …` — a file's revision history.
#[derive(Subcommand)]
enum VersionCmd {
/// List a file's versions, newest first.
List {
/// File path, inside the mountpoint or relative to it.
path: PathBuf,
},
/// Make an earlier version the file's current content.
///
/// The server applies this in the background, so the new content may take a
/// moment to show up in the mount.
Restore {
/// File path, inside the mountpoint or relative to it.
path: PathBuf,
/// Version id, from `pdfs versions list`.
id: String,
},
/// Write an earlier version to a local file, leaving the file untouched.
Save {
/// File path, inside the mountpoint or relative to it.
path: PathBuf,
/// Version id, from `pdfs versions list`.
id: String,
/// Where to write it. Must not already exist.
dest: PathBuf,
},
/// Permanently delete an earlier version. The content is unrecoverable, and
/// the version that is currently active cannot be deleted.
Rm {
/// File path, inside the mountpoint or relative to it.
path: PathBuf,
/// Version id, from `pdfs versions list`.
id: String,
},
}
#[derive(Subcommand)]
enum DeviceCmd {
/// List registered devices.
@@ -529,7 +582,12 @@ fn main() -> Result<()> {
Command::Unpin { path } => cmd_unpin(path),
Command::Pins => cmd_pins(),
Command::Ls { path } => cmd_ls(path),
Command::Photos { limit, offset } => cmd_photos(limit, offset),
Command::Photos {
limit,
offset,
favorites,
} => cmd_photos(limit, offset, favorites),
Command::Favorite { uid, remove } => cmd_favorite(uid, remove),
Command::Albums => cmd_albums(),
Command::Album { uid, limit, offset } => cmd_album(uid, limit, offset),
Command::OpenPhoto { uid } => cmd_open_photo(uid),
@@ -561,6 +619,7 @@ fn main() -> Result<()> {
role,
} => cmd_share_role(path, id, kind, role),
Command::Unshare { path, id, kind } => cmd_unshare(path, id, kind),
Command::Versions { action } => cmd_versions(action),
Command::PublicLink { action } => cmd_public_link(action),
Command::Shared => cmd_shared(),
Command::SharedWithMe { uid } => cmd_shared_with_me(uid),
@@ -788,6 +847,71 @@ fn cmd_share(
})?)
}
fn cmd_versions(action: VersionCmd) -> Result<()> {
match action {
VersionCmd::List { path } => {
let response = control_request(CtlRequest::ListRevisions {
path: path_arg(&path)?,
})?;
if emit_json(&response)? {
return Ok(());
}
match response {
CtlResponse::Revisions { items } if items.is_empty() => {
println!("No versions.");
}
CtlResponse::Revisions { items } => {
for r in items {
// The claimed plaintext size is what the user recognises;
// the storage size is ciphertext and always larger.
let size = r
.claimed_size
.filter(|size| *size >= 0)
.unwrap_or(r.size_on_storage)
.max(0) as u64;
let mark = if r.is_active { "*" } else { " " };
let by = r
.signed_by
.map(|email| format!(" {email}"))
.unwrap_or_default();
println!(
"{mark} {:<20} {:>10} {}{by}",
r.id,
human_bytes(size),
format_epoch(r.created),
);
}
println!("(* = current)");
}
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
VersionCmd::Restore { path, id } => {
ok_or_bail(control_request(CtlRequest::RestoreRevision {
path: path_arg(&path)?,
revision_id: id,
})?)
}
VersionCmd::Save { path, id, dest } => {
// The daemon writes the file, so it needs a path that means the same
// thing in its process as in this one.
let dest = std::path::absolute(&dest)
.with_context(|| format!("resolve {}", dest.display()))?;
ok_or_bail(control_request(CtlRequest::SaveRevisionAs {
path: path_arg(&path)?,
revision_id: id,
dest: dest.to_string_lossy().into_owned(),
})?)
}
VersionCmd::Rm { path, id } => ok_or_bail(control_request(CtlRequest::DeleteRevision {
path: path_arg(&path)?,
revision_id: id,
})?),
}
}
fn cmd_members(path: PathBuf) -> Result<()> {
match control_request(CtlRequest::ListShare {
path: path_arg(&path)?,
@@ -1487,12 +1611,13 @@ fn cmd_ls(path: Option<PathBuf>) -> Result<()> {
Ok(())
}
fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
fn cmd_photos(limit: usize, offset: usize, favorites: bool) -> Result<()> {
match control_request(CtlRequest::PhotosTimeline {
offset,
limit,
kind: None,
range: None,
favorites,
})? {
CtlResponse::Photos {
available: false, ..
@@ -1511,6 +1636,13 @@ fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
///
/// A timeline or album reply only carries thumbnails already in the cache, so
/// the rest of the page's are pulled in one batch rather than left blank.
fn cmd_favorite(uid: String, remove: bool) -> Result<()> {
ok_or_bail(control_request(CtlRequest::SetPhotoFavorite {
uid,
favorite: !remove,
})?)
}
fn print_photo_page(items: Vec<pdfs_core::control::PhotoItem>) -> Result<()> {
let uids: Vec<String> = items.iter().map(|p| p.uid.clone()).collect();
let thumbs: HashMap<String, Option<String>> =
@@ -1524,7 +1656,8 @@ fn print_photo_page(items: Vec<pdfs_core::control::PhotoItem>) -> Result<()> {
.and_then(|p| p.as_deref())
.or(p.thumb_path.as_deref())
.unwrap_or("(no thumbnail)");
println!("{} {} {thumb}", p.capture_time, p.uid);
let fav = if p.favorite { "*" } else { " " };
println!("{fav} {} {} {thumb}", p.capture_time, p.uid);
}
Ok(())
}
+1
View File
@@ -21,4 +21,5 @@ sha2.workspace = true
rusqlite.workspace = true
keyring.workspace = true
parking_lot.workspace = true
async-trait.workspace = true
libc.workspace = true
+58 -1
View File
@@ -9,8 +9,10 @@
use keyring::Entry;
use proton_drive_rs::{KeySalt, ProtonDriveClient};
use proton_sdk::api::{HumanVerification, HumanVerificationCredential};
use proton_sdk::cache::EncryptedCacheRepository;
use proton_sdk::config::ProtonClientConfiguration;
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
use proton_sdk::telemetry::TracingTelemetry;
use serde::{Deserialize, Serialize};
use crate::config::{APP_VERSION, AppDirs, KEYRING_SERVICE, USER_AGENT};
@@ -220,6 +222,21 @@ pub fn load() -> Result<StoredSession> {
/// Forget the persisted session (best-effort; absent entry is not an error).
pub fn logout() -> Result<()> {
// The persisted entity cache describes the account being logged out of. It
// is encrypted under that account's mailbox password, but leaving it behind
// would also mean the next account inherits a store it can only ever read as
// misses. Best-effort: failing to remove a cache must not block the logout.
if let Ok(dirs) = AppDirs::new() {
let path = dirs.state_dir().join("sdk_cache.db");
if let Some(cache) = crate::sdkcache::SdkCache::opened()
&& let Err(e) = cache.clear_now()
{
tracing::warn!(error = %e, "clearing the SDK entity cache on logout failed");
}
for suffix in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
}
}
match keyring_entry()?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(e.into()),
@@ -259,10 +276,50 @@ pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
register_refresh_handler(&session, password.clone(), key_salts.clone());
let client = ProtonDriveClient::with_key_salts(&session, password.into_bytes(), key_salts);
let client = tune(
ProtonDriveClient::with_key_salts(&session, password.clone().into_bytes(), key_salts),
password.as_bytes(),
);
Ok((client, session))
}
/// The client settings every front-end wants, applied wherever a
/// [`ProtonDriveClient`] is built for real work.
///
/// - **Telemetry** into `tracing`, so the SDK's own spans (transfers, block
/// storage, per-request timings) land in the daemon's log next to the
/// daemon's, instead of being dropped by the default no-op sink.
/// - **Small-file uploads**: a file that fits in one block is uploaded as a
/// single atomic request instead of the multi-step draft/block/commit dance.
/// The SDK makes this opt-in because it has no remote feature-flag provider;
/// the drain queue writes plenty of small files, and the shorter path is one
/// failure point per write instead of three.
/// - **A persistent entity cache** ([`crate::sdkcache`]), so a restart does not
/// re-fetch and re-decrypt the tree the previous run already walked. It is
/// wrapped in the SDK's [`EncryptedCacheRepository`] keyed by the mailbox
/// password, so the decrypted node names it holds are not readable from the
/// file alone — and a password change simply reads as a cold cache, since the
/// SDK treats an undecryptable entry as a miss and clears the store.
///
/// Opening it is best-effort: a store this process cannot open costs cache
/// hits, not the session, so the client falls back to the SDK's in-memory
/// default.
fn tune(client: ProtonDriveClient, mailbox_password: &[u8]) -> ProtonDriveClient {
let client = client
.with_telemetry(TracingTelemetry::shared())
.with_small_file_upload(true);
match AppDirs::new().and_then(|dirs| crate::sdkcache::SdkCache::shared(&dirs)) {
Ok(cache) => client.with_entity_repository(EncryptedCacheRepository::shared(
cache,
mailbox_password.to_vec(),
)),
Err(e) => {
tracing::warn!(error = %e, "persistent SDK entity cache unavailable; using memory");
client
}
}
}
/// Write the session's current tokens back to the keyring.
///
/// The session does not carry the mailbox password (it is only needed to rebuild
+84
View File
@@ -0,0 +1,84 @@
//! Per-node outcomes of the SDK's batch node operations.
//!
//! `trash_nodes`, `restore_nodes`, `delete_nodes` and `move_nodes` report one
//! outcome per node instead of failing the whole call: a node the server
//! rejects leaves the rest of the batch applied. The daemon's callers fall into
//! two shapes, and both are here so neither has to re-derive the fold:
//!
//! - one node in, one answer out — [`into_unit`], which collapses the single
//! outcome back to a `Result`;
//! - a user-supplied list — [`split`], which keeps the successes and hands back
//! the failures to report.
use proton_sdk::error::ProtonError;
use proton_sdk::ids::NodeUid;
/// One node's outcome, as the SDK batch operations report it.
pub type Outcome = (NodeUid, Result<(), ProtonError>);
/// Collapse a batch result to the first per-node failure, for the callers that
/// pass exactly one node and want the old all-or-nothing answer.
///
/// An empty outcome list is `Ok`: the server accepted the request and reported
/// on nothing, which is not an error the caller can act on.
pub fn into_unit(outcomes: Vec<Outcome>) -> Result<(), ProtonError> {
for (_, outcome) in outcomes {
outcome?;
}
Ok(())
}
/// Split a batch result into the nodes that succeeded and the ones that failed.
pub fn split(outcomes: Vec<Outcome>) -> (Vec<NodeUid>, Vec<(NodeUid, ProtonError)>) {
let mut done = Vec::with_capacity(outcomes.len());
let mut failed = Vec::new();
for (uid, outcome) in outcomes {
match outcome {
Ok(()) => done.push(uid),
Err(e) => failed.push((uid, e)),
}
}
(done, failed)
}
#[cfg(test)]
mod tests {
use super::*;
use proton_sdk::ids::{LinkId, VolumeId};
fn uid(link: &str) -> NodeUid {
NodeUid::new(VolumeId::new("vol"), LinkId::new(link))
}
fn failure() -> ProtonError {
ProtonError::invalid_operation("nope")
}
#[test]
fn into_unit_is_ok_when_every_node_succeeded() {
assert!(into_unit(vec![(uid("a"), Ok(())), (uid("b"), Ok(()))]).is_ok());
assert!(
into_unit(Vec::new()).is_ok(),
"nothing reported is not a failure"
);
}
#[test]
fn into_unit_surfaces_a_failed_node() {
let outcomes = vec![(uid("a"), Ok(())), (uid("b"), Err(failure()))];
assert!(into_unit(outcomes).is_err());
}
#[test]
fn split_keeps_successes_and_failures_apart() {
let outcomes = vec![
(uid("a"), Ok(())),
(uid("b"), Err(failure())),
(uid("c"), Ok(())),
];
let (done, failed) = split(outcomes);
assert_eq!(done, vec![uid("a"), uid("c")]);
assert_eq!(failed.len(), 1);
assert_eq!(failed[0].0, uid("b"));
}
}
+178
View File
@@ -63,6 +63,10 @@ pub enum Request {
/// timeline. Like `kind`, the offset is relative to the filtered set.
#[serde(default)]
range: Option<(i64, i64)>,
/// Restrict the page to favourited photos. Older front-ends omit it and
/// get the whole timeline, as before.
#[serde(default)]
favorites: bool,
},
/// The months the timeline spans (newest first, with per-month counts) so a
/// front-end can build a date scrubber without paging the whole library.
@@ -91,6 +95,14 @@ pub enum Request {
PhotoThumbs { uids: Vec<String> },
/// Download a photo's full content into the cache; replies with its path.
OpenPhoto { uid: String },
/// Add or remove Proton's `Favorite` tag on a photo. Replies with
/// [`Response::Ok`].
///
/// Favouriting a photo that is *not* on this account's own photos volume —
/// one shared with us, or one that lives only in an album — needs it
/// re-encrypted for our timeline, which the SDK does not implement yet; the
/// daemon reports that as an error rather than silently doing nothing.
SetPhotoFavorite { uid: String, favorite: bool },
/// Upload the photo at `source_path` under the given name and media type.
///
/// A path, not the bytes: this protocol is line-delimited JSON, and
@@ -325,6 +337,39 @@ pub enum Request {
/// The by-uid twin of [`Request::RemovePublicLink`].
RemovePublicLinkByUid { uid: String, id: String },
// ---- revisions --------------------------------------------------------
/// List the version history of the file at mountpoint-relative `path`,
/// newest first. Replies with [`Response::Revisions`].
ListRevisions { path: String },
/// The by-uid twin of [`Request::ListRevisions`].
ListRevisionsByUid { uid: String },
/// Make revision `revision_id` of the file at `path` current again. The
/// server applies this asynchronously, so the restored content may take a
/// moment to appear. Replies with [`Response::Ok`].
RestoreRevision { path: String, revision_id: String },
/// The by-uid twin of [`Request::RestoreRevision`].
RestoreRevisionByUid { uid: String, revision_id: String },
/// Permanently delete revision `revision_id` of the file at `path`. The
/// content is unrecoverable, and the server refuses to delete the revision
/// that is currently active. Replies with [`Response::Ok`].
DeleteRevision { path: String, revision_id: String },
/// The by-uid twin of [`Request::DeleteRevision`].
DeleteRevisionByUid { uid: String, revision_id: String },
/// Write revision `revision_id` of the file at `path` to the absolute local
/// path `dest`, leaving the file's current content untouched. Replies with
/// [`Response::Ok`].
SaveRevisionAs {
path: String,
revision_id: String,
dest: String,
},
/// The by-uid twin of [`Request::SaveRevisionAs`].
SaveRevisionAsByUid {
uid: String,
revision_id: String,
dest: String,
},
// ---- shared by me -----------------------------------------------------
/// List the nodes I have shared with others — collaborative shares that still
/// have members, pending invitations or a public link. Replies with
@@ -608,6 +653,36 @@ pub struct SharedItem {
pub link: Option<PublicLinkInfo>,
}
/// One entry of a file's version history (in [`Response::Revisions`]).
///
/// The size and modification time are the ones the *uploader* claimed in the
/// revision's extended attributes, so an old client that wrote none leaves them
/// `None`; `size_on_storage` is always known but counts ciphertext, which is
/// larger than the file. A front-end shows the claimed size when it has one and
/// falls back to the storage size.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct RevisionInfo {
/// Server-assigned id — the handle for [`Request::RestoreRevision`],
/// [`Request::DeleteRevision`] and [`Request::SaveRevisionAs`].
pub id: String,
/// True for the revision that is the file's current content. It cannot be
/// deleted, and restoring it is a no-op.
pub is_active: bool,
/// When the revision was created, epoch seconds.
pub created: i64,
/// Encrypted size on Proton's storage, in bytes.
pub size_on_storage: i64,
/// Plaintext size as claimed by the uploader, when it wrote one.
pub claimed_size: Option<i64>,
/// Modification time as claimed by the uploader (ISO-8601, verbatim).
pub claimed_modified: Option<String>,
/// The address that signed the revision, when it was not signed anonymously
/// by the node key.
pub signed_by: Option<String>,
/// Whether the revision carries thumbnails.
pub has_thumbnails: bool,
}
/// What happened, in an [`ActivityEntry`]. Kept coarse: a front-end maps each to
/// an icon and a verb, and the human detail lives in the entry's fields.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
@@ -1014,6 +1089,11 @@ pub struct PhotoItem {
/// the split omit it; a front-end then treats everything as a still photo.
#[serde(default = "default_photo_kind")]
pub kind: PhotoKind,
/// Whether the photo is favourited. Older daemons omit it, which a front-end
/// reads as "not favourited" — the same thing it would show for a photo whose
/// tags it has not learned yet.
#[serde(default)]
pub favorite: bool,
}
/// A daemon too old to classify a timeline entry is assumed to have served a
@@ -1209,6 +1289,9 @@ pub enum Response {
AccountQuota { max_space: i64, used_space: i64 },
/// Nodes I have shared with others (reply to [`Request::ListSharedByMe`]).
SharedByMe { items: Vec<SharedItem> },
/// A file's version history, newest first (reply to
/// [`Request::ListRevisions`]).
Revisions { items: Vec<RevisionInfo> },
/// The daemon's recent activity, newest first (reply to
/// [`Request::ListActivity`]).
Activity { items: Vec<ActivityEntry> },
@@ -1858,6 +1941,101 @@ mod tests {
);
}
/// The revision requests come in path and by-uid pairs like the share ones,
/// and carry a revision id that must survive the round-trip verbatim: acting
/// on the wrong revision deletes content the user meant to keep.
#[test]
fn revision_requests_roundtrip_in_both_addressing_forms() {
let cases = [
(
Request::ListRevisions { path: "a/b".into() },
r#"{"ListRevisions":{"path":"a/b"}}"#,
),
(
Request::ListRevisionsByUid {
uid: "vol~link".into(),
},
r#"{"ListRevisionsByUid":{"uid":"vol~link"}}"#,
),
(
Request::RestoreRevision {
path: "a/b".into(),
revision_id: "rev-1".into(),
},
r#"{"RestoreRevision":{"path":"a/b","revision_id":"rev-1"}}"#,
),
(
Request::RestoreRevisionByUid {
uid: "vol~link".into(),
revision_id: "rev-1".into(),
},
r#"{"RestoreRevisionByUid":{"uid":"vol~link","revision_id":"rev-1"}}"#,
),
(
Request::DeleteRevision {
path: "a/b".into(),
revision_id: "rev-2".into(),
},
r#"{"DeleteRevision":{"path":"a/b","revision_id":"rev-2"}}"#,
),
(
Request::DeleteRevisionByUid {
uid: "vol~link".into(),
revision_id: "rev-2".into(),
},
r#"{"DeleteRevisionByUid":{"uid":"vol~link","revision_id":"rev-2"}}"#,
),
(
Request::SaveRevisionAs {
path: "a/b".into(),
revision_id: "rev-3".into(),
dest: "/tmp/out.bin".into(),
},
r#"{"SaveRevisionAs":{"path":"a/b","revision_id":"rev-3","dest":"/tmp/out.bin"}}"#,
),
(
Request::SaveRevisionAsByUid {
uid: "vol~link".into(),
revision_id: "rev-3".into(),
dest: "/tmp/out.bin".into(),
},
r#"{"SaveRevisionAsByUid":{"uid":"vol~link","revision_id":"rev-3","dest":"/tmp/out.bin"}}"#,
),
];
for (request, wire) in cases {
assert_eq!(serde_json::to_string(&request).unwrap(), wire);
let decoded: Request = serde_json::from_str(wire).unwrap();
assert_eq!(serde_json::to_string(&decoded).unwrap(), wire);
}
}
/// A front-end that predates the favourites filter sends no `favorites`
/// field, and must keep getting the whole timeline rather than an empty page.
#[test]
fn timeline_requests_without_the_favorites_filter_still_decode() {
let decoded: Request =
serde_json::from_str(r#"{"PhotosTimeline":{"offset":0,"limit":50}}"#).unwrap();
assert!(matches!(
decoded,
Request::PhotosTimeline {
favorites: false,
kind: None,
range: None,
..
}
));
let wire = serde_json::to_string(&Request::SetPhotoFavorite {
uid: "vol~link".into(),
favorite: true,
})
.unwrap();
assert_eq!(
wire,
r#"{"SetPhotoFavorite":{"uid":"vol~link","favorite":true}}"#
);
}
#[test]
fn path_share_requests_keep_their_existing_wire_shape() {
let cases = [
+8
View File
@@ -178,6 +178,10 @@ impl Db {
ratio: r.get(3)?,
thumb_state: r.get(4)?,
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
// Album rows have no favourite state of their own: an album
// photo on someone else's volume is not in our timeline, and
// favouriting one is not supported yet anyway.
favorite: false,
})
})?
.collect::<rusqlite::Result<_>>()?;
@@ -218,6 +222,10 @@ impl Db {
ratio: r.get(3)?,
thumb_state: r.get(4)?,
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
// Album rows have no favourite state of their own: an album
// photo on someone else's volume is not in our timeline, and
// favouriting one is not supported yet anyway.
favorite: false,
})
})?
.collect::<rusqlite::Result<_>>()?;
+17 -1
View File
@@ -9,7 +9,7 @@ use super::Db;
use crate::Result;
/// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 19;
pub(super) const SCHEMA_VERSION: i64 = 20;
impl Db {
pub(super) fn migrate(&self) -> Result<()> {
@@ -98,6 +98,9 @@ impl Db {
if current < 19 {
tx.execute_batch(MIGRATION_V19)?;
}
if current < 20 {
tx.execute_batch(MIGRATION_V20)?;
}
tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -511,3 +514,16 @@ CREATE TABLE album_photos (
CREATE INDEX idx_album_photos_seq ON album_photos(album_uid, seq);
CREATE INDEX idx_album_photos_uid ON album_photos(uid);
";
/// V20: whether a photo is a favourite.
///
/// A column rather than a tag table: `Favorite` is the only Proton photo tag a
/// user sets by hand (the rest — Video, Screenshot, Selfie, Raw… — are the
/// server's own classification, and the Photos page already derives its tabs
/// from the media type). Defaulting to 0 means an existing timeline stays
/// intact and each row learns its real value on the next refresh, the same way
/// `media_type` did.
const MIGRATION_V20: &str = "
ALTER TABLE photos ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_photos_favorite ON photos(favorite, seq);
";
+1 -1
View File
@@ -49,7 +49,7 @@ pub use ops::{
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL,
PendingCounts, PendingOp, RenameMeta, op_supersedes,
};
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN};
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
pub use sync::{StoredSyncEntry, StoredSyncFolder};
pub use trash::StoredTrash;
+51 -18
View File
@@ -32,13 +32,21 @@ pub struct StoredPhoto {
/// Which Photos-page tab this entry belongs to, derived from its name and
/// media type when the timeline was last replaced.
pub kind: crate::control::PhotoKind,
/// Whether the photo carries Proton's `Favorite` tag.
pub favorite: bool,
}
/// One row of a timeline replacement: `(uid, capture_time, name, media_type,
/// favorite)`. The two trailing options are "what this refresh learned" — a
/// `None` keeps whatever is already stored rather than clearing it.
pub type TimelineRow = (String, i64, Option<String>, Option<String>, Option<bool>);
impl Db {
pub fn photos_replace(
&self,
items: &[(String, i64, Option<String>, Option<String>)],
) -> Result<()> {
/// Replace the timeline wholesale. Each item is
/// `(uid, capture_time, name, media_type, favorite)`; `favorite` is `None`
/// when the refresh could not resolve that photo's node, in which case the
/// flag already stored is kept rather than silently cleared.
pub fn photos_replace(&self, items: &[TimelineRow]) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
// `media_type` is learned-and-kept like the ratio and thumb verdict: the
@@ -46,10 +54,14 @@ impl Db {
// not know a photo's media type yet when it replaces the timeline. Keep
// 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 learned: HashMap<String, (Option<f64>, i64, Option<String>, bool)> = {
let mut stmt =
tx.prepare("SELECT uid, ratio, thumb_state, media_type, favorite FROM photos")?;
let rows = stmt.query_map([], |r| {
Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?, r.get(3)?)))
Ok((
r.get::<_, String>(0)?,
(r.get(1)?, r.get(2)?, r.get(3)?, r.get::<_, i64>(4)? != 0),
))
})?;
rows.collect::<rusqlite::Result<_>>()?
};
@@ -58,16 +70,16 @@ impl Db {
{
let mut stmt = tx.prepare(
"INSERT INTO photos
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
)?;
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));
for (seq, (uid, capture_time, name, media_type, favorite)) in items.iter().enumerate() {
let (ratio, thumb_state, learned_media, learned_favorite) = learned
.get(uid)
.cloned()
.unwrap_or((None, THUMB_UNKNOWN, None, false));
let media_type = media_type.clone().or(learned_media);
let favorite = favorite.unwrap_or(learned_favorite);
// 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.
@@ -82,6 +94,7 @@ impl Db {
seq as i64,
media_type,
kind.as_i64(),
favorite as i64,
])?;
}
}
@@ -93,18 +106,21 @@ impl Db {
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
/// `favorites` restricts the page to favourited photos.
pub fn photos_page(
&self,
offset: usize,
limit: usize,
kind: Option<crate::control::PhotoKind>,
range: Option<(i64, i64)>,
favorites: bool,
) -> Result<Vec<StoredPhoto>> {
let conn = self.conn.lock();
// Built up so any combination of the optional filters is one indexed
// query rather than a statement per case.
let mut sql =
String::from("SELECT uid, capture_time, name, ratio, thumb_state, kind FROM photos");
let mut sql = String::from(
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos",
);
let mut binds: Vec<i64> = Vec::new();
let mut conds: Vec<String> = Vec::new();
if let Some(k) = kind {
@@ -117,6 +133,9 @@ impl Db {
binds.push(to);
conds.push(format!("capture_time < ?{}", binds.len()));
}
if favorites {
conds.push("favorite = 1".to_string());
}
if !conds.is_empty() {
sql.push_str(" WHERE ");
sql.push_str(&conds.join(" AND "));
@@ -139,6 +158,7 @@ impl Db {
ratio: r.get(3)?,
thumb_state: r.get(4)?,
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
favorite: r.get::<_, i64>(6)? != 0,
})
})?
.collect::<rusqlite::Result<_>>()?;
@@ -206,7 +226,7 @@ impl Db {
let conn = self.conn.lock();
let placeholders = vec!["?"; uids.len()].join(",");
let mut stmt = conn.prepare(&format!(
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM photos
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos
WHERE uid IN ({placeholders})"
))?;
let rows = stmt.query_map(rusqlite::params_from_iter(uids), |r| {
@@ -217,6 +237,7 @@ impl Db {
ratio: r.get(3)?,
thumb_state: r.get(4)?,
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
favorite: r.get::<_, i64>(6)? != 0,
})
})?;
let mut photos = Vec::new();
@@ -226,6 +247,18 @@ impl Db {
Ok(photos)
}
/// Record a photo's favourite flag locally, after the server accepted the
/// change. A uid the timeline does not hold is a no-op — an album photo on
/// someone else's volume is never in our own `photos` table.
pub fn photos_set_favorite(&self, uid: &str, favorite: bool) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE photos SET favorite = ?2 WHERE uid = ?1",
params![uid, favorite as i64],
)?;
Ok(())
}
/// Record what a thumbnail attempt learned: whether the photo now has one
/// ([`THUMB_HAVE`] / [`THUMB_NONE`]), and its aspect ratio if the pixels were
/// seen. A `None` ratio leaves any previously learned one alone.
+61 -12
View File
@@ -113,9 +113,9 @@ fn node_from(parent: serde_json::Value, link: &str, name: &str, kind: serde_json
fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
let db = Db::open_in_memory().unwrap();
db.photos_replace(&[
("p1".into(), 300, None, Some("video/mp4".into())),
("p2".into(), 200, None, None),
("p3".into(), 100, None, None),
("p1".into(), 300, None, Some("video/mp4".into()), Some(true)),
("p2".into(), 200, None, None, None),
("p3".into(), 100, None, None, None),
])
.unwrap();
@@ -126,13 +126,13 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
// The next refresh brings a new photo, keeps p1 and p2, and loses p3.
// p1's media type arrives as `None` this time and must not be forgotten.
db.photos_replace(&[
("p0".into(), 400, Some("new.jpg".into()), None),
("p1".into(), 300, None, None),
("p2".into(), 200, None, None),
("p0".into(), 400, Some("new.jpg".into()), None, Some(false)),
("p1".into(), 300, None, None, None),
("p2".into(), 200, None, None, None),
])
.unwrap();
let page = db.photos_page(0, 10, None, None).unwrap();
let page = db.photos_page(0, 10, None, None, false).unwrap();
assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p0", "p1", "p2"],
@@ -155,7 +155,7 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
// The counts break down by tab, and a filtered page returns only its tab.
assert_eq!(db.photos_counts().unwrap(), (2, 1, 0));
let videos = db
.photos_page(0, 10, Some(crate::control::PhotoKind::Video), None)
.photos_page(0, 10, Some(crate::control::PhotoKind::Video), None, false)
.unwrap();
assert_eq!(
videos.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
@@ -167,7 +167,9 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
// A date-range page keeps only the window's photos: [150, 350) is p1+p2,
// not p0 at 400. Combined with a kind filter both conditions apply.
let ranged = db.photos_page(0, 10, None, Some((150, 350))).unwrap();
let ranged = db
.photos_page(0, 10, None, Some((150, 350)), false)
.unwrap();
assert_eq!(
ranged.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p1", "p2"]
@@ -178,6 +180,7 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
10,
Some(crate::control::PhotoKind::Video),
Some((150, 350)),
false,
)
.unwrap();
assert_eq!(
@@ -193,20 +196,52 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
assert_eq!(months[0].2, 3);
}
#[test]
fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
let db = Db::open_in_memory().unwrap();
db.photos_replace(&[
("p1".into(), 300, None, None, Some(true)),
("p2".into(), 200, None, None, Some(false)),
])
.unwrap();
let favorites = db.photos_page(0, 10, None, None, true).unwrap();
assert_eq!(
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p1"],
"only the favourited photo is in a favourites page"
);
// A local toggle survives a refresh that could not resolve the photo's node
// (`None`), and loses to one that could.
db.photos_set_favorite("p2", true).unwrap();
db.photos_replace(&[
("p1".into(), 300, None, None, Some(false)),
("p2".into(), 200, None, None, None),
])
.unwrap();
let favorites = db.photos_page(0, 10, None, None, true).unwrap();
assert_eq!(
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p2"],
"the server's answer wins where it has one; the local flag holds where it doesn't"
);
assert!(db.photos_by_uid(&["p2".into()]).unwrap()[0].favorite);
}
#[test]
fn photos_page_slices_the_timeline_in_order() {
let db = Db::open_in_memory().unwrap();
let items: Vec<_> = (0..5)
.map(|i| (format!("p{i}"), 500 - i as i64, None, None::<String>))
.map(|i| (format!("p{i}"), 500 - i as i64, None, None::<String>, None))
.collect();
db.photos_replace(&items).unwrap();
let page = db.photos_page(2, 2, None, None).unwrap();
let page = db.photos_page(2, 2, None, None, false).unwrap();
assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p2", "p3"]
);
assert!(db.photos_page(9, 2, None, None).unwrap().is_empty());
assert!(db.photos_page(9, 2, None, None, false).unwrap().is_empty());
}
#[test]
@@ -1336,6 +1371,8 @@ fn migration_v17_adds_share_access_to_a_v16_fixture() {
DROP TABLE share_access;
DROP TABLE album_photos;
DROP TABLE albums;
DROP INDEX idx_photos_favorite;
ALTER TABLE photos DROP COLUMN favorite;
UPDATE sync_state SET value = '16' WHERE key = 'schema_version';",
)
.unwrap();
@@ -1402,6 +1439,18 @@ fn migration_v18_projects_v17_sync_folders_and_cascades_deletes() {
VALUES
(41, '/home/me/Existing', 'vol~existing', 'share-existing',
'ondemand', 'syncing', 123);
-- The timeline as V17 held it: later migrations add columns to it,
-- so a fixture without it isn't a database any release produced.
CREATE TABLE photos (
uid TEXT PRIMARY KEY,
capture_time INTEGER NOT NULL,
name TEXT,
ratio REAL,
thumb_state INTEGER NOT NULL DEFAULT 0,
seq INTEGER NOT NULL,
media_type TEXT,
kind INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE share_access (
root_uid TEXT PRIMARY KEY,
access TEXT NOT NULL
+2
View File
@@ -4,6 +4,7 @@
pub mod access;
pub mod auth;
pub mod batch;
pub mod cache;
pub mod config;
pub mod control;
@@ -12,6 +13,7 @@ pub mod error;
pub mod localindex;
pub mod mounts;
pub mod profile;
pub mod sdkcache;
pub mod search;
pub mod service;
pub mod shell;
+283
View File
@@ -0,0 +1,283 @@
//! A persistent [`CacheRepository`] for the SDK's Drive entity cache.
//!
//! The SDK caches decrypted node metadata (name, size, parent, the share whose
//! membership signs operations on it) so navigating a tree does not re-fetch and
//! re-decrypt what it already resolved. That cache defaults to memory, which
//! means every daemon restart pays for the whole walk again. This backs it with
//! SQLite so it survives one.
//!
//! **Its own database file, not `cache.db`.** The daemon's `Db` is a single
//! `Mutex<Connection>` shared by every FUSE thread and the control socket; SDK
//! cache traffic is frequent, small, and entirely reconstructible, so putting it
//! on that mutex would add contention to the daemon's hottest lock (and to a
//! write-ahead log whose growth is already sensitive to how long that lock is
//! held) for no durability benefit. A separate file also means no schema
//! migration: this store can be deleted and rebuilt at any time.
//!
//! **What it holds.** Decrypted metadata, never key material — the SDK keeps
//! node keys and hash keys in a separate in-memory secret cache that nothing
//! here can reach. The same names and sizes are already persisted in `cache.db`
//! (`nodes`), so this file widens no exposure that the state directory did not
//! already have; `AppDirs::ensure` is what keeps that directory private.
//!
//! **Staleness.** A persisted cache outlives the process, so an entry can
//! describe a node another client has since changed. The daemon's event loop is
//! what closes that: it resumes from a persisted cursor and calls the SDK's
//! `invalidate_caches_for_event` for every event it replays, including the ones
//! that happened while the daemon was down. The one case that leaves no trail is
//! a *seeded* cursor — a first-ever mount, or a cursor the daemon lost — where
//! nothing says what changed, so the caller [`clear`](SdkCache::clear)s this
//! store instead of trusting it.
use std::path::Path;
use std::sync::{Arc, OnceLock};
use async_trait::async_trait;
use parking_lot::Mutex;
use proton_sdk::cache::CacheRepository;
use proton_sdk::error::{ProtonError, Result as SdkResult};
use rusqlite::{Connection, params};
use crate::config::AppDirs;
use crate::error::Result;
/// Process-wide handle, so the daemon's event loop can clear the same store the
/// client is writing through without threading it down every call.
static SHARED: OnceLock<Arc<SdkCache>> = OnceLock::new();
/// SQLite-backed entity cache for the SDK.
pub struct SdkCache {
conn: Mutex<Connection>,
}
impl SdkCache {
/// Open (creating if needed) the entity cache under the app's state
/// directory. The first call wins; later calls return the same handle, which
/// is what lets the event loop clear the store the Drive client is using.
pub fn shared(dirs: &AppDirs) -> Result<Arc<Self>> {
if let Some(existing) = SHARED.get() {
return Ok(existing.clone());
}
let cache = Arc::new(Self::open(&dirs.state_dir().join("sdk_cache.db"))?);
Ok(SHARED.get_or_init(|| cache).clone())
}
/// The already-open shared cache, if one was opened in this process.
pub fn opened() -> Option<Arc<Self>> {
SHARED.get().cloned()
}
/// Open the store at `path`, creating its schema.
pub fn open(path: &Path) -> Result<Self> {
let conn = Connection::open(path)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
// Tags cascade with their entry, so a `set` that replaces a value cannot
// leave the old value's tags behind pointing at the new one.
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS entry (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tag (
key TEXT NOT NULL REFERENCES entry(key) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (key, tag)
);
CREATE INDEX IF NOT EXISTS idx_tag_tag ON tag(tag);",
)?;
Ok(Self {
conn: Mutex::new(conn),
})
}
/// Drop every entry. Used when the daemon cannot prove the cache is current
/// — a seeded event cursor — and on logout.
pub fn clear_now(&self) -> Result<()> {
let conn = self.conn.lock();
conn.execute_batch("DELETE FROM tag; DELETE FROM entry;")?;
Ok(())
}
fn set_now(&self, key: &str, value: &str, tags: &[String]) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
tx.execute(
"INSERT INTO entry (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![key, value],
)?;
tx.execute("DELETE FROM tag WHERE key = ?1", params![key])?;
{
let mut stmt = tx.prepare("INSERT INTO tag (key, tag) VALUES (?1, ?2)")?;
for tag in tags {
stmt.execute(params![key, tag])?;
}
}
tx.commit()?;
Ok(())
}
fn get_now(&self, key: &str) -> Result<Option<String>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare("SELECT value FROM entry WHERE key = ?1")?;
let mut rows = stmt.query(params![key])?;
match rows.next()? {
Some(row) => Ok(Some(row.get(0)?)),
None => Ok(None),
}
}
fn remove_now(&self, key: &str) -> Result<()> {
let conn = self.conn.lock();
conn.execute("DELETE FROM entry WHERE key = ?1", params![key])?;
Ok(())
}
fn remove_by_tag_now(&self, tag: &str) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"DELETE FROM entry WHERE key IN (SELECT key FROM tag WHERE tag = ?1)",
params![tag],
)?;
Ok(())
}
/// Entries carrying **all** of `tags` — the set intersection the SDK's
/// `get_by_tags` contract specifies, not a union.
fn get_by_tags_now(&self, tags: &[String]) -> Result<Vec<(String, String)>> {
if tags.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.lock();
let placeholders = vec!["?"; tags.len()].join(",");
let sql = format!(
"SELECT e.key, e.value FROM entry e
JOIN tag t ON t.key = e.key
WHERE t.tag IN ({placeholders})
GROUP BY e.key
HAVING COUNT(DISTINCT t.tag) = {}",
tags.len()
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(tags), |r| {
Ok((r.get(0)?, r.get(1)?))
})?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
}
}
/// A cache failure is never fatal to the operation that triggered it — the SDK
/// treats this repository as best-effort — but it still has to come back as the
/// SDK's error type.
fn sdk_error(e: crate::error::Error) -> ProtonError {
ProtonError::invalid_operation(format!("sdk entity cache: {e}"))
}
// Every method is a local SQLite statement on its own connection: microseconds,
// no network, no shared lock with the daemon's database. Running them inline on
// the caller's task is cheaper than the thread hop that moving them off would
// cost.
#[async_trait]
impl CacheRepository for SdkCache {
async fn set(&self, key: &str, value: &str, tags: &[String]) -> SdkResult<()> {
self.set_now(key, value, tags).map_err(sdk_error)
}
async fn get(&self, key: &str) -> SdkResult<Option<String>> {
self.get_now(key).map_err(sdk_error)
}
async fn remove(&self, key: &str) -> SdkResult<()> {
self.remove_now(key).map_err(sdk_error)
}
async fn remove_by_tag(&self, tag: &str) -> SdkResult<()> {
self.remove_by_tag_now(tag).map_err(sdk_error)
}
async fn clear(&self) -> SdkResult<()> {
self.clear_now().map_err(sdk_error)
}
async fn get_by_tags(&self, tags: &[String]) -> SdkResult<Vec<(String, String)>> {
self.get_by_tags_now(tags).map_err(sdk_error)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cache() -> SdkCache {
let conn = Connection::open_in_memory().unwrap();
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
conn.execute_batch(
"CREATE TABLE entry (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE tag (
key TEXT NOT NULL REFERENCES entry(key) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (key, tag)
);",
)
.unwrap();
SdkCache {
conn: Mutex::new(conn),
}
}
fn tags(values: &[&str]) -> Vec<String> {
values.iter().map(|t| t.to_string()).collect()
}
#[test]
fn set_replaces_the_value_and_its_tags() {
let cache = cache();
cache.set_now("k", "one", &tags(&["a", "b"])).unwrap();
cache.set_now("k", "two", &tags(&["b"])).unwrap();
assert_eq!(cache.get_now("k").unwrap().as_deref(), Some("two"));
// The tag dropped by the second write must not still select the entry —
// that is how a stale invalidation would miss it.
assert!(cache.get_by_tags_now(&tags(&["a"])).unwrap().is_empty());
assert_eq!(cache.get_by_tags_now(&tags(&["b"])).unwrap().len(), 1);
}
#[test]
fn get_by_tags_intersects_rather_than_unions() {
let cache = cache();
cache.set_now("both", "1", &tags(&["x", "y"])).unwrap();
cache.set_now("one", "2", &tags(&["x"])).unwrap();
let hits = cache.get_by_tags_now(&tags(&["x", "y"])).unwrap();
assert_eq!(hits, vec![("both".to_string(), "1".to_string())]);
// An empty tag list selects nothing, matching the SDK contract.
assert!(cache.get_by_tags_now(&[]).unwrap().is_empty());
}
#[test]
fn removing_by_tag_takes_the_entry_and_its_tags() {
let cache = cache();
cache.set_now("k", "v", &tags(&["gone"])).unwrap();
cache.remove_by_tag_now("gone").unwrap();
assert!(cache.get_now("k").unwrap().is_none());
assert!(cache.get_by_tags_now(&tags(&["gone"])).unwrap().is_empty());
}
#[test]
fn clear_empties_the_store() {
let cache = cache();
cache.set_now("a", "1", &tags(&["t"])).unwrap();
cache.set_now("b", "2", &[]).unwrap();
cache.clear_now().unwrap();
assert!(cache.get_now("a").unwrap().is_none());
assert!(cache.get_now("b").unwrap().is_none());
}
}
+1
View File
@@ -10,6 +10,7 @@ description = "FUSE on-demand filesystem for Proton Drive"
[dependencies]
pdfs-core.workspace = true
proton-drive-rs.workspace = true
futures.workspace = true
tokio.workspace = true
tracing.workspace = true
fuser.workspace = true
+8
View File
@@ -358,6 +358,14 @@ pub(super) async fn run_event_sync(
// started offline (offline.md Phase 1) — so retry rather than giving up,
// which used to disable live sync for the life of the daemon.
Ok(None) => {
// Nothing says what changed before this cursor, so a persisted SDK
// entity cache from an earlier run cannot be trusted: drop it rather
// than serve metadata no event will ever invalidate.
if let Some(cache) = pdfs_core::sdkcache::SdkCache::opened()
&& let Err(e) = cache.clear_now()
{
warn!(error = %e, "clearing the persisted SDK entity cache failed");
}
let mut delay = ONLINE_PROBE_MIN;
loop {
match client.enumerate_events(&scope, None).await {
+87 -1
View File
@@ -203,7 +203,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
limit,
kind,
range,
}) => match core.photos_timeline(offset, limit, kind, range) {
favorites,
}) => match core.photos_timeline(offset, limit, kind, range, favorites) {
Ok(Some(items)) => CtlResponse::Photos {
available: true,
items,
@@ -216,6 +217,19 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::SetPhotoFavorite { uid, favorite }) => match parse_uid(&uid) {
Some(node_uid) => match core.set_photo_favorite(&node_uid, favorite) {
Ok(()) => CtlResponse::Ok {
message: if favorite {
"added to favourites".to_string()
} else {
"removed from favourites".to_string()
},
},
Err(e) => CtlResponse::error(e),
},
None => CtlResponse::error(CoreError::invalid(format!("invalid uid: {uid}"))),
},
Ok(CtlRequest::PhotoAlbums) => match core.albums() {
Ok(Some(items)) => CtlResponse::Albums {
available: true,
@@ -863,6 +877,78 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::ListRevisions { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.list_revisions(&rel) {
Ok(items) => CtlResponse::Revisions { items },
Err(e) => CtlResponse::error(e),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::ListRevisionsByUid { uid }) => match core.list_revisions_by_uid(&uid) {
Ok(items) => CtlResponse::Revisions { items },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::RestoreRevision { path, revision_id }) => {
match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.restore_revision(&rel, &revision_id) {
Ok(()) => CtlResponse::Ok {
message: format!("restoring version {revision_id} of {path}"),
},
Err(e) => CtlResponse::error(e),
},
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::RestoreRevisionByUid { uid, revision_id }) => {
match core.restore_revision_by_uid(&uid, &revision_id) {
Ok(()) => CtlResponse::Ok {
message: format!("restoring version {revision_id}"),
},
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::DeleteRevision { path, revision_id }) => {
match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.delete_revision(&rel, &revision_id) {
Ok(()) => CtlResponse::Ok {
message: format!("deleted version {revision_id} of {path}"),
},
Err(e) => CtlResponse::error(e),
},
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::DeleteRevisionByUid { uid, revision_id }) => {
match core.delete_revision_by_uid(&uid, &revision_id) {
Ok(()) => CtlResponse::Ok {
message: format!("deleted version {revision_id}"),
},
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::SaveRevisionAs {
path,
revision_id,
dest,
}) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.save_revision_as(&rel, &revision_id, Path::new(&dest)) {
Ok(written) => CtlResponse::Ok {
message: format!("wrote {} to {dest}", human_bytes(written)),
},
Err(e) => CtlResponse::error(e),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::SaveRevisionAsByUid {
uid,
revision_id,
dest,
}) => match core.save_revision_as_by_uid(&uid, &revision_id, Path::new(&dest)) {
Ok(written) => CtlResponse::Ok {
message: format!("wrote {} to {dest}", human_bytes(written)),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::ListSharedWithMe) => match core.list_shared_with_me() {
Ok(entries) => CtlResponse::Entries { entries },
Err(e) => CtlResponse::error(e),
+5 -1
View File
@@ -12,6 +12,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use parking_lot::Mutex;
use pdfs_core::batch;
use pdfs_core::control::{
ActivityKind, DeviceInfo, JobItem, MountKind, MountSpec, SyncFolderInfo, SyncPhase,
SyncProgress,
@@ -557,7 +558,10 @@ impl Core {
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
if delete_remote
&& let Some(uid) = parse_uid(&folder.remote_uid)
&& let Err(e) = self.rt.block_on(self.client.trash_nodes(&[uid]))
&& let Err(e) = self
.rt
.block_on(self.client.trash_nodes(&[uid]))
.and_then(batch::into_unit)
{
warn!(id, error = %e, "delete remote device folder failed");
}
+2
View File
@@ -25,6 +25,7 @@ use std::path::{Path, PathBuf};
use std::sync::atomic::Ordering;
use std::time::Duration;
use pdfs_core::batch;
use pdfs_core::cache::{Baseline, StagedWrite};
use pdfs_core::control::{ActivityKind, TransferDirection};
use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, RenameMeta};
@@ -340,6 +341,7 @@ impl Core {
match self
.rt
.block_on(self.client.trash_nodes(std::slice::from_ref(&uid)))
.and_then(batch::into_unit)
{
Ok(()) => {}
Err(e) if is_gone(&e) => {
+1
View File
@@ -1856,6 +1856,7 @@ impl ProtonFs {
.core
.rt
.block_on(self.core.client.trash_nodes(std::slice::from_ref(&uid)))
.and_then(batch::into_unit)
{
error!(%uid, error = %e, "trash failed");
self.core
+64 -9
View File
@@ -53,6 +53,8 @@ use fuser::{
ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyIoctl, ReplyLseek, ReplyOpen, ReplyWrite, Request,
Session, TimeOrNow, WriteFlags,
};
use futures::StreamExt as _;
use pdfs_core::batch;
use pdfs_core::cache::{BLOCK_SIZE, Baseline, ContentCache, StagedWrite};
use pdfs_core::config::{AppDirs, SweepMode};
use pdfs_core::control::{
@@ -88,6 +90,7 @@ mod mount;
mod photos;
mod profile;
mod reads;
mod revisions;
mod sharing;
mod state;
mod sweep;
@@ -3287,6 +3290,7 @@ impl Core {
.map_err(|error| self.errno_error(error, "trash access"))?;
self.rt
.block_on(self.client.trash_nodes(std::slice::from_ref(&uid)))
.and_then(batch::into_unit)
.map_err(|e| CoreError::from_api(&e, "trash"))?;
let name = self
.state
@@ -3325,6 +3329,7 @@ impl Core {
if let Err(e) = self
.rt
.block_on(self.client.trash_nodes(std::slice::from_ref(uid)))
.and_then(batch::into_unit)
{
error!(%uid, name, error = %e, "trashing the node a rename replaces failed");
self.log_activity(ActivityKind::Trash, name, e.to_string(), false);
@@ -3372,6 +3377,7 @@ impl Core {
match self
.rt
.block_on(self.client.restore_nodes(std::slice::from_ref(uid)))
.and_then(batch::into_unit)
{
Ok(()) => {
self.invalidate_trash();
@@ -3586,14 +3592,37 @@ impl Core {
.into_iter()
.filter_map(|n| n.parent_uid)
.collect();
// Streamed per-node outcomes: a uid the server refuses (already restored,
// gone) leaves the rest of the batch restored, and each node is unhidden
// as *its* batch lands rather than after the last one — a daemon killed
// half way through a large restore leaves local state describing what the
// server actually did. Only a batch that restored nothing is an error the
// caller can act on.
let mut restored: Vec<NodeUid> = Vec::with_capacity(parsed.len());
let mut first_error: Option<ProtonError> = None;
self.rt
.block_on(self.client.restore_nodes(&parsed))
.block_on(async {
let mut outcomes = std::pin::pin!(self.client.restore_nodes_streaming(&parsed));
while let Some(item) = outcomes.next().await {
let (uid, outcome) = item?;
match outcome {
Ok(()) => {
self.hidden.lock().remove(&uid);
restored.push(uid);
}
Err(e) => {
warn!(%uid, error = %e, "restore failed for a node");
first_error.get_or_insert(e);
}
}
}
Ok::<(), ProtonError>(())
})
.map_err(|e| CoreError::from_api(&e, "restore"))?;
if restored.is_empty()
&& let Some(error) = first_error
{
let mut hidden = self.hidden.lock();
for uid in &parsed {
hidden.remove(uid);
}
return Err(CoreError::from_api(&error, "restore"));
}
// Keyed by uid, so it applies to every mount: a restored node reappears
// in whichever inode spaces show its parent, not only the primary one.
@@ -3605,19 +3634,45 @@ impl Core {
}
});
self.invalidate_trash();
Ok(parsed.len())
Ok(restored.len())
}
/// Permanently delete trashed nodes. Irreversible on the server; locally it
/// drops any metadata and cached content the node still owns.
fn delete_forever(&self, uids: &[String]) -> CoreResult<usize> {
let parsed = Self::parse_uids(uids)?;
// Streamed, and each node's local state is dropped as its batch lands.
// A permanent delete is irreversible, so the useful property is that a
// daemon interrupted mid-batch has already forgotten exactly the nodes
// the server destroyed — never more, never fewer.
let mut deleted = 0usize;
let mut first_error: Option<ProtonError> = None;
self.rt
.block_on(self.client.delete_nodes(&parsed))
.block_on(async {
let mut outcomes = std::pin::pin!(self.client.delete_nodes_streaming(&parsed));
while let Some(item) = outcomes.next().await {
let (uid, outcome) = item?;
match outcome {
Ok(()) => {
self.drop_local(std::slice::from_ref(&uid));
deleted += 1;
}
Err(e) => {
warn!(%uid, error = %e, "permanent delete failed for a node");
first_error.get_or_insert(e);
}
}
}
Ok::<(), ProtonError>(())
})
.map_err(|e| CoreError::from_api(&e, "delete"))?;
self.drop_local(&parsed);
if deleted == 0
&& let Some(error) = first_error
{
return Err(CoreError::from_api(&error, "delete"));
}
self.invalidate_trash();
Ok(parsed.len())
Ok(deleted)
}
/// Permanently delete everything in the trash. The uids are listed first so
+57 -7
View File
@@ -14,7 +14,7 @@ use pdfs_core::{CoreError, CoreResult};
use std::sync::atomic::Ordering;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use proton_drive_rs::{NodeKind, ThumbnailType};
use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, ThumbnailType};
use tracing::{info, warn};
use super::{
@@ -97,6 +97,7 @@ impl Core {
limit: usize,
kind: Option<PhotoKind>,
range: Option<(i64, i64)>,
favorites: bool,
) -> CoreResult<Option<Vec<PhotoItem>>> {
let count = self.db.photos_count().map_err(CoreError::from)?;
if count == 0 {
@@ -116,7 +117,7 @@ impl Core {
let page = self
.db
.photos_page(offset, limit, kind, range)
.photos_page(offset, limit, kind, range, favorites)
.map_err(CoreError::from)?;
Ok(Some(page.into_iter().map(|p| self.photo_item(p)).collect()))
}
@@ -139,9 +140,42 @@ impl Core {
ratio: photo.ratio,
no_thumb: photo.thumb_state == db::THUMB_NONE,
kind: photo.kind,
favorite: photo.favorite,
}
}
/// Add or remove Proton's `Favorite` tag on a photo, then record the change
/// locally so the gallery reflects it without waiting for a timeline refresh.
///
/// The SDK reports one outcome per photo rather than failing the call, so the
/// single outcome is unwrapped back into this request's result.
pub(crate) fn set_photo_favorite(&self, uid: &NodeUid, favorite: bool) -> CoreResult<()> {
let photos = self.photos();
let update = PhotoTagsUpdate {
node_uid: uid.clone(),
tags_to_add: if favorite {
vec![PhotoTag::Favorite]
} else {
Vec::new()
},
tags_to_remove: if favorite {
Vec::new()
} else {
vec![PhotoTag::Favorite]
},
};
let outcomes = self
.rt
.block_on(photos.update_photos(std::slice::from_ref(&update)))
.map_err(|e| CoreError::from_api(&e, "update photo tags"))?;
pdfs_core::batch::into_unit(outcomes)
.map_err(|e| CoreError::from_api(&e, "update photo tags"))?;
self.db
.photos_set_favorite(&uid.to_string(), favorite)
.map_err(CoreError::from)?;
Ok(())
}
/// Thumbnails for `uids`, served from the cache, fetched from the server for
/// whatever is missing, and — for the photos the server has no thumbnail for
/// at all — *generated locally* from the full file (see
@@ -381,7 +415,7 @@ impl Core {
// learned before (or classifies from nothing, i.e. a still photo), so a
// partial resolve never blanks the timeline.
let uids: Vec<NodeUid> = items.iter().map(|it| it.uid.clone()).collect();
let mut meta: HashMap<String, (Option<String>, Option<String>)> = HashMap::new();
let mut meta: HashMap<String, (Option<String>, Option<String>, bool)> = HashMap::new();
for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) {
match photos.enumerate_nodes(chunk).await {
Ok(nodes) => {
@@ -390,19 +424,35 @@ impl Core {
NodeKind::File { media_type, .. } => Some(media_type.clone()),
NodeKind::Folder => None,
};
meta.insert(node.uid.to_string(), (Some(node.name), media_type));
// The favourite state is a tag on the photo node, so it
// rides along with the metadata resolve rather than
// costing a listing of its own.
let favorite = node
.photo
.as_ref()
.is_some_and(|p| p.tags.contains(&PhotoTag::Favorite));
meta.insert(
node.uid.to_string(),
(Some(node.name), media_type, favorite),
);
}
}
Err(e) => warn!(error = %e, "resolving photo metadata for a timeline chunk failed"),
}
}
let rows: Vec<(String, i64, Option<String>, Option<String>)> = items
let rows: Vec<db::TimelineRow> = items
.iter()
.map(|it| {
let key = it.uid.to_string();
let (name, media_type) = meta.get(&key).cloned().unwrap_or((None, None));
(key, it.capture_time, name, media_type)
// An unresolved photo keeps whatever was learned before, for the
// favourite flag as much as for the name and media type.
match meta.get(&key).cloned() {
Some((name, media_type, favorite)) => {
(key, it.capture_time, name, media_type, Some(favorite))
}
None => (key, it.capture_time, None, None, None),
}
})
.collect();
self.db.photos_replace(&rows).map_err(CoreError::from)?;
+264
View File
@@ -0,0 +1,264 @@
//! File version history: listing a file's revisions, restoring one, deleting
//! one, and writing an old one out to a local path.
//!
//! Proton Drive keeps every revision a client ever committed, and until now the
//! daemon only ever addressed the active one — a file that a sync pass or a
//! `(sync-conflict)` fork overwrote could only be recovered from whatever the
//! local `recovery/` directory happened to hold. This is the remote equivalent,
//! and it is the reason a *restore* here is not a download-then-upload: the
//! server re-points the file at an existing revision, so no content crosses the
//! wire and nothing lands in the drain queue.
//!
//! Two things about the SDK surface leak into every method here:
//!
//! - **A restore is asynchronous.** The server answers 202 and swaps the active
//! revision in the background, so the daemon drops its cached content for the
//! file rather than trying to describe the new one. The next read repopulates
//! from whatever is live by then.
//! - **The active revision cannot be deleted**, and the server — not this code —
//! is what enforces that. [`Core::delete_revision_for_uid`] refuses locally
//! too, so the user gets a sentence instead of an API code.
use std::path::Path;
use pdfs_core::control::{ActivityKind, RevisionInfo};
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::Revision;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use tracing::info;
use super::Core;
/// A [`Revision`] as the control protocol reports it.
fn revision_info(revision: Revision) -> RevisionInfo {
RevisionInfo {
is_active: revision.is_active(),
id: revision.revision_id,
created: revision.creation_time,
size_on_storage: revision.size_on_storage,
claimed_size: revision.claimed_size,
claimed_modified: revision.claimed_modification_time,
// An empty signer means the node key signed it anonymously, which is not
// an address a front-end can show.
signed_by: revision.signature_email.filter(|email| !email.is_empty()),
has_thumbnails: revision.has_thumbnails,
}
}
impl Core {
/// The version history of the file at mountpoint-relative `rel`.
pub(crate) fn list_revisions(&self, rel: &Path) -> CoreResult<Vec<RevisionInfo>> {
let (_ino, uid) = self.resolve(rel)?;
self.list_revisions_for_uid(&uid)
}
/// [`Core::list_revisions`] for a node addressed through any daemon location.
pub(crate) fn list_revisions_by_uid(&self, uid: &str) -> CoreResult<Vec<RevisionInfo>> {
let uid = self.resolve_anywhere(uid)?;
self.list_revisions_for_uid(&uid)
}
fn list_revisions_for_uid(&self, uid: &NodeUid) -> CoreResult<Vec<RevisionInfo>> {
let revisions = self
.rt
.block_on(self.client.enumerate_revisions(uid))
.map_err(|e| CoreError::from_api(&e, "list revisions"))?;
Ok(revisions.into_iter().map(revision_info).collect())
}
/// Make `revision_id` the current content of the file at `rel`.
pub(crate) fn restore_revision(&self, rel: &Path, revision_id: &str) -> CoreResult<()> {
let (_ino, uid) = self.resolve(rel)?;
let name = rel
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| uid.to_string());
self.restore_revision_for_uid(&uid, revision_id, &name)?;
// The size and mtime a listing shows describe the revision that was
// active a moment ago.
self.invalidate_parent_listing(rel);
Ok(())
}
/// [`Core::restore_revision`] for a node addressed through any daemon
/// location.
pub(crate) fn restore_revision_by_uid(&self, uid: &str, revision_id: &str) -> CoreResult<()> {
let uid = self.resolve_anywhere(uid)?;
let name = uid.to_string();
self.restore_revision_for_uid(&uid, revision_id, &name)
}
fn restore_revision_for_uid(
&self,
uid: &NodeUid,
revision_id: &str,
name: &str,
) -> CoreResult<()> {
// Restoring rewrites the file's content: the same authority a write
// needs, not the one a read needs.
self.require_uid_writable(uid)
.map_err(|error| self.errno_error(error, "restore revision"))?;
if let Err(e) = self
.rt
.block_on(self.client.restore_revision(uid, revision_id))
{
let error = CoreError::from_api(&e, "restore revision");
self.log_activity(ActivityKind::Restore, name, &error, false);
return Err(error);
}
// The active revision is changing behind our back — anything describing
// the old one (cached blocks, an open reader, the size in a listing) is
// stale from here on, and the server may not have applied the swap yet.
self.cache.evict(uid);
self.evict_reader(uid);
self.log_activity(
ActivityKind::Restore,
name,
format!("restored version {revision_id}"),
true,
);
info!(%uid, revision_id, name, "restored an earlier revision");
Ok(())
}
/// Permanently delete `revision_id` from the history of the file at `rel`.
pub(crate) fn delete_revision(&self, rel: &Path, revision_id: &str) -> CoreResult<()> {
let (_ino, uid) = self.resolve(rel)?;
let name = rel
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| uid.to_string());
self.delete_revision_for_uid(&uid, revision_id, &name)
}
/// [`Core::delete_revision`] for a node addressed through any daemon
/// location.
pub(crate) fn delete_revision_by_uid(&self, uid: &str, revision_id: &str) -> CoreResult<()> {
let uid = self.resolve_anywhere(uid)?;
let name = uid.to_string();
self.delete_revision_for_uid(&uid, revision_id, &name)
}
fn delete_revision_for_uid(
&self,
uid: &NodeUid,
revision_id: &str,
name: &str,
) -> CoreResult<()> {
self.require_uid_writable(uid)
.map_err(|error| self.errno_error(error, "delete revision"))?;
// The server rejects deleting the active revision; saying so here is a
// sentence the user can act on instead of an API code, and it costs one
// listing the front-end already made to show the version list.
let revisions = self
.rt
.block_on(self.client.enumerate_revisions(uid))
.map_err(|e| CoreError::from_api(&e, "list revisions"))?;
let target = revisions
.iter()
.find(|r| r.revision_id == revision_id)
.ok_or_else(|| CoreError::not_found(format!("no version {revision_id} on {name}")))?;
if target.is_active() {
return Err(CoreError::invalid(format!(
"version {revision_id} is the current content of {name}; restore another version first"
)));
}
self.rt
.block_on(self.client.delete_revision(uid, revision_id))
.map_err(|e| CoreError::from_api(&e, "delete revision"))?;
self.log_activity(
ActivityKind::DeleteForever,
name,
format!("deleted version {revision_id}"),
true,
);
info!(%uid, revision_id, name, "deleted a revision");
Ok(())
}
/// Write `revision_id` of the file at `rel` to the absolute local path
/// `dest`, leaving the file itself untouched.
pub(crate) fn save_revision_as(
&self,
rel: &Path,
revision_id: &str,
dest: &Path,
) -> CoreResult<u64> {
let (_ino, uid) = self.resolve(rel)?;
let name = rel
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| uid.to_string());
self.save_revision_for_uid(&uid, revision_id, &name, dest)
}
/// [`Core::save_revision_as`] for a node addressed through any daemon
/// location.
pub(crate) fn save_revision_as_by_uid(
&self,
uid: &str,
revision_id: &str,
dest: &Path,
) -> CoreResult<u64> {
let uid = self.resolve_anywhere(uid)?;
let name = uid.to_string();
self.save_revision_for_uid(&uid, revision_id, &name, dest)
}
fn save_revision_for_uid(
&self,
uid: &NodeUid,
revision_id: &str,
name: &str,
dest: &Path,
) -> CoreResult<u64> {
if !dest.is_absolute() {
return Err(CoreError::invalid(format!(
"destination must be an absolute path: {}",
dest.display()
)));
}
// Never silently replace something the user already has: an old version
// written over the wrong file is exactly the loss this feature exists to
// undo.
if dest.exists() {
return Err(CoreError::invalid(format!(
"{} already exists",
dest.display()
)));
}
let file = std::fs::File::create(dest)
.map_err(|e| CoreError::internal(format!("create {}: {e}", dest.display())))?;
let mut out = std::io::BufWriter::new(file);
let written = match self
.rt
.block_on(self.client.download_revision_to(uid, revision_id, &mut out))
.map_err(|e| CoreError::from_api(&e, "download revision"))
.and_then(|()| {
use std::io::Write as _;
out.flush()
.map_err(|e| CoreError::internal(format!("write {}: {e}", dest.display())))?;
let written = std::fs::metadata(dest)
.map(|m| m.len())
.map_err(|e| CoreError::internal(format!("stat {}: {e}", dest.display())))?;
Ok(written)
}) {
Ok(written) => written,
Err(error) => {
// A half-written file looks like a successful export to every
// tool that opens it afterwards.
let _ = std::fs::remove_file(dest);
self.log_activity(ActivityKind::Download, name, &error, false);
return Err(error);
}
};
self.log_activity(
ActivityKind::Download,
name,
format!("saved version {revision_id} to {}", dest.display()),
true,
);
info!(%uid, revision_id, dest = %dest.display(), "wrote an earlier revision to disk");
Ok(written)
}
}
+2
View File
@@ -21,6 +21,7 @@
use std::collections::HashMap;
use std::time::Duration;
use pdfs_core::batch;
use pdfs_core::config::SweepMode;
use pdfs_core::control::ActivityKind;
use proton_drive_rs::proton_sdk::ids::NodeUid;
@@ -149,6 +150,7 @@ impl Core {
match self
.rt
.block_on(self.client.trash_nodes(std::slice::from_ref(uid)))
.and_then(batch::into_unit)
{
Ok(()) => {}
Err(e) if super::is_gone(&e) => {}
+12 -2
View File
@@ -38,6 +38,7 @@ use tracing::{debug, info, warn};
use crate::transfers::{CountingWriter, OwnedCountingReader};
use crate::{Core, now_secs, parse_uid};
use pdfs_core::batch;
use pdfs_core::control::{ActivityKind, TransferDirection};
use pdfs_core::db::{StoredSyncEntry, StoredSyncFolder};
use pdfs_core::syncignore::IgnoreRules;
@@ -513,7 +514,11 @@ impl Core {
let _ = self.db.sync_entry_remove(folder_id, rel);
continue;
};
if let Err(e) = self.rt.block_on(self.client.trash_nodes(&[uid])) {
if let Err(e) = self
.rt
.block_on(self.client.trash_nodes(&[uid]))
.and_then(batch::into_unit)
{
warn!(rel, error = %e, "sync: trash remote failed");
self.log_activity(ActivityKind::Trash, base_name(rel), e.to_string(), false);
outcome.errors += 1;
@@ -754,6 +759,7 @@ impl Core {
if let Err(e) = self
.rt
.block_on(self.client.trash_nodes(std::slice::from_ref(&remote.uid)))
.and_then(batch::into_unit)
{
warn!(rel, error = %e, "sync: trash remote failed");
self.log_activity(
@@ -808,7 +814,11 @@ impl Core {
}
delete_remote_dirs.sort_by_key(|(p, _)| std::cmp::Reverse(p.matches('/').count()));
for (rel, uid) in delete_remote_dirs {
if let Err(e) = self.rt.block_on(self.client.trash_nodes(&[uid])) {
if let Err(e) = self
.rt
.block_on(self.client.trash_nodes(&[uid]))
.and_then(batch::into_unit)
{
warn!(rel, error = %e, "sync: trash remote folder failed");
self.log_activity(ActivityKind::Trash, base_name(&rel), e.to_string(), false);
outcome.errors += 1;
+3
View File
@@ -18,6 +18,7 @@ use pages::trash::*;
use pages::verify::*;
use widgets::details::*;
use widgets::share_dialog::*;
use widgets::versions_dialog::*;
use std::cell::{Cell, RefCell};
@@ -424,6 +425,8 @@ fn build_window(app: &adw::Application) {
dates: gallery_widgets.dates.clone(),
date_ranges: RefCell::new(vec![None]),
range: Cell::new(None),
favorites_btn: gallery_widgets.favorites_btn.clone(),
favorites: Cell::new(false),
date_suppress: Cell::new(false),
loading: Cell::new(false),
width: Cell::new(0),
@@ -19,6 +19,11 @@ pub(crate) struct Viewer {
pub(crate) info_map: gtk4::Button,
/// Coordinates behind `info_map`, once a photo with GPS tags is shown.
pub(crate) coords: RefCell<Option<(f64, f64)>>,
/// The favourite toggle in the top bar, and whether it is being set from
/// the model rather than by the user — the same suppression the details pane
/// uses for its pin switch, so painting a photo doesn't fire a round-trip.
pub(crate) favorite: gtk4::ToggleButton,
pub(crate) favorite_suppress: Cell<bool>,
/// uid of the photo currently on screen.
pub(crate) uid: RefCell<String>,
/// On-disk path of the full-size photo, once it has been downloaded.
@@ -41,6 +46,38 @@ pub(crate) struct ExifInfo {
pub(crate) coords: Option<(f64, f64)>,
}
/// Paint the favourite toggle without firing its handler.
pub(crate) fn show_favorite(viewer: &Rc<Viewer>, favorite: bool) {
viewer.favorite_suppress.set(true);
viewer.favorite.set_active(favorite);
set_favorite_icon(&viewer.favorite, favorite);
viewer.favorite_suppress.set(false);
}
/// Filled star for a favourite, outline for the rest.
fn set_favorite_icon(button: &gtk4::ToggleButton, favorite: bool) {
button.set_icon_name(if favorite {
"starred-symbolic"
} else {
"non-starred-symbolic"
});
}
/// Write a favourite change back into the loaded gallery page, so returning to
/// the grid (or reopening the photo) shows what was just set without a reload.
fn set_gallery_favorite(ui: &Rc<Ui>, uid: &str, favorite: bool) {
let Some(idx) = find_photo_index(&ui.gallery.model, uid) else {
return;
};
let Some(boxed) = ui.gallery.model.item(idx).and_downcast::<BoxedAnyObject>() else {
return;
};
let mut item = boxed.borrow::<PhotoItem>().clone();
item.favorite = favorite;
ui.gallery.model.remove(idx);
ui.gallery.model.insert(idx, &BoxedAnyObject::new(item));
}
/// Show the photo behind `uid`: paint its (already cached) thumbnail immediately
/// so the lightbox never opens on a blank screen, ask the daemon for the
/// full-size file, and swap it in — plus its EXIF — when it lands.
@@ -52,6 +89,14 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
*viewer.path.borrow_mut() = None;
clear_info(viewer);
// The favourite state comes from the timeline page the gallery already has,
// so the button is right on the first frame instead of after a round-trip.
let favorite = find_photo_index(&ui.gallery.model, &uid)
.and_then(|idx| ui.gallery.model.item(idx))
.and_downcast::<BoxedAnyObject>()
.is_some_and(|boxed| boxed.borrow::<PhotoItem>().favorite);
show_favorite(viewer, favorite);
// The thumbnail the gallery already decoded stands in for the full photo
// while it downloads: blurry for a moment beats black for a second.
match ui.gallery.photo_tex.borrow().get(&uid) {
@@ -450,6 +495,14 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
info_toggle.add_css_class("flat");
info_toggle.add_css_class("viewer-action-btn");
let favorite_btn = gtk4::ToggleButton::builder()
.icon_name("non-starred-symbolic")
.tooltip_text("Favourite")
.valign(gtk4::Align::Center)
.build();
favorite_btn.add_css_class("flat");
favorite_btn.add_css_class("viewer-action-btn");
let download_btn = action("document-save-symbolic", "Save a copy…");
let open_ext_btn = action("document-open-symbolic", "Open with another app");
let close_btn = action("window-close-symbolic", "Close (Esc)");
@@ -460,6 +513,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
top_bar.set_valign(gtk4::Align::Start);
top_bar.set_hexpand(true);
top_bar.append(&titles);
top_bar.append(&favorite_btn);
top_bar.append(&info_toggle);
top_bar.append(&download_btn);
top_bar.append(&open_ext_btn);
@@ -558,6 +612,8 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
info_revealer: info_revealer.clone(),
info_rows,
info_map: info_map.clone(),
favorite: favorite_btn.clone(),
favorite_suppress: Cell::new(false),
coords: RefCell::new(None),
uid: RefCell::new(initial_uid.clone()),
path: RefCell::new(None),
@@ -601,6 +657,44 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
}
});
let ui_fav = ui.clone();
let viewer_fav = viewer.clone();
favorite_btn.connect_toggled(move |btn| {
if viewer_fav.favorite_suppress.get() {
return;
}
let favorite = btn.is_active();
set_favorite_icon(btn, favorite);
let uid = viewer_fav.uid.borrow().clone();
let rx = spawn_request(
ui_fav.dirs.control_socket(),
Request::SetPhotoFavorite {
uid: uid.clone(),
favorite,
},
);
let ui_result = ui_fav.clone();
let viewer_result = viewer_fav.clone();
glib::spawn_future_local(async move {
let failed = match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => {
set_gallery_favorite(&ui_result, &uid, favorite);
None
}
Ok(Ok(Response::Error { message, .. })) => Some(message),
_ => Some("The mount service didn't respond.".to_string()),
};
if let Some(detail) = failed {
toast_error(&ui_result, "Couldn't change the favourite", &detail);
// The server refused, so the button must go back to describing
// what is actually stored — without firing this handler again.
if *viewer_result.uid.borrow() == uid {
show_favorite(&viewer_result, !favorite);
}
}
});
});
let w_download = window.clone();
let viewer_download = viewer.clone();
download_btn.connect_clicked(move |_| {
+30
View File
@@ -63,6 +63,10 @@ pub(crate) struct GalleryState {
/// The capture-time window the timeline is currently filtered to, or `None`
/// for the whole span. Read by [`load_gallery`], set by the date dropdown.
pub(crate) range: Cell<Option<(i64, i64)>>,
/// The favourites toggle, and whether it is on. When on, the timeline is
/// restricted to photos carrying Proton's `Favorite` tag.
pub(crate) favorites_btn: gtk4::ToggleButton,
pub(crate) favorites: Cell<bool>,
/// Set while the date dropdown is being repopulated, so resetting its model
/// doesn't fire the selection handler and kick off a spurious reload.
pub(crate) date_suppress: Cell<bool>,
@@ -188,6 +192,7 @@ pub(crate) struct GalleryWidgets {
/// The All / Photos / Videos / Raw filter toggles, in that order (index maps
/// to [`kind_for_tab`]).
pub(crate) tabs: [gtk4::ToggleButton; 4],
pub(crate) favorites_btn: gtk4::ToggleButton,
/// The date-jump dropdown, populated with the timeline's months.
pub(crate) dates: gtk4::DropDown,
}
@@ -314,6 +319,15 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
tab_group.append(btn);
}
// Favourites: a filter, not a tab — it cuts across Photos / Videos / Raw, so
// it stays outside the segmented control rather than becoming a fifth option
// that would silently drop the kind the user picked.
let favorites_btn = gtk4::ToggleButton::builder()
.icon_name("starred-symbolic")
.tooltip_text("Show only favourites")
.build();
favorites_btn.add_css_class("pill");
// Date jump: "All dates" plus a row per month, filled in once the timeline's
// months are known (see [`refresh_photo_months`]). Pushed to the far end of
// the filter row, opposite the kind toggles.
@@ -326,6 +340,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
let filters = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
filters.set_hexpand(true);
filters.append(&tab_group);
filters.append(&favorites_btn);
let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
spacer.set_hexpand(true);
filters.append(&spacer);
@@ -415,6 +430,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
upload,
refresh,
tabs,
favorites_btn,
dates,
albums,
albums_stack,
@@ -679,6 +695,19 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: &gtk4::ListView, scroll: &gtk4::Sc
});
}
// Favourites: reload the timeline restricted to favourites (or back to all).
// Independent of the kind tabs and the date jump, both of which keep their
// current value across the toggle.
let ui_fav = ui.clone();
ui.gallery.favorites_btn.connect_toggled(move |btn| {
let on = btn.is_active();
if ui_fav.gallery.favorites.get() == on {
return;
}
ui_fav.gallery.favorites.set(on);
load_gallery(&ui_fav, false);
});
// Date jump: selecting a month loads that window; "All dates" (row 0) clears
// it. Skipped while the model is being repopulated (see `gallery_date_suppress`).
let ui_dates = ui.clone();
@@ -1378,6 +1407,7 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
limit: PHOTOS_PAGE,
kind: ui.gallery.kind.get(),
range: ui.gallery.range.get(),
favorites: ui.gallery.favorites.get(),
},
};
let rx = spawn_request(ui.dirs.control_socket(), request);
@@ -26,6 +26,8 @@ pub(crate) struct DetailsWidgets {
pub(crate) pin_row: adw::SwitchRow,
pub(crate) open_button: gtk4::Button,
pub(crate) rename_button: gtk4::Button,
/// Files only — folders have no revision history.
pub(crate) versions_button: gtk4::Button,
pub(crate) trash_button: gtk4::Button,
pub(crate) close_button: gtk4::Button,
}
@@ -96,6 +98,8 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
open_button.add_css_class("pill");
let rename_button = gtk4::Button::builder().label("Rename").build();
rename_button.add_css_class("pill");
let versions_button = gtk4::Button::builder().label("Versions").build();
versions_button.add_css_class("pill");
let trash_button = gtk4::Button::builder().label("Move to Trash").build();
trash_button.add_css_class("destructive-action");
trash_button.add_css_class("pill");
@@ -104,6 +108,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
actions.set_margin_top(6);
actions.append(&open_button);
actions.append(&rename_button);
actions.append(&versions_button);
actions.append(&trash_button);
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
@@ -137,6 +142,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
pin_row,
open_button,
rename_button,
versions_button,
trash_button,
close_button,
},
@@ -190,6 +196,16 @@ pub(crate) fn wire_details(ui: &Rc<Ui>) {
prompt_rename(&ui_rename, &entry);
}
});
let ui_versions = ui.clone();
ui.details
.details
.versions_button
.connect_clicked(move |_| {
let entry = ui_versions.details.details_entry.borrow().clone();
if let Some(entry) = entry {
open_versions_dialog(&ui_versions, &entry);
}
});
let ui_trash = ui.clone();
ui.details.details.trash_button.connect_clicked(move |_| {
let entry = ui_trash.details.details_entry.borrow().clone();
@@ -246,6 +262,9 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
d.pin_row.set_sensitive(*ui.mounted.borrow());
d.open_button
.set_label(if entry.is_dir { "Open folder" } else { "Open" });
// Only files have revisions, and only a connected daemon can fetch them.
d.versions_button.set_visible(!entry.is_dir);
d.versions_button.set_sensitive(*ui.mounted.borrow());
ui.details.details_suppress.set(false);
*ui.details.details_entry.borrow_mut() = Some(entry.clone());
+1
View File
@@ -1,2 +1,3 @@
pub(crate) mod details;
pub(crate) mod share_dialog;
pub(crate) mod versions_dialog;
@@ -0,0 +1,353 @@
//! The per-file **Versions** dialog: what Proton Drive still holds of a file's
//! earlier content, and the three things a user can do with one.
//!
//! Every row is one revision the server kept. Restoring re-points the file at an
//! old revision (server-side, so nothing is uploaded and the mount may take a
//! moment to catch up); saving writes an old revision out to a local file
//! without touching the live one; deleting is permanent, and the active revision
//! cannot be deleted at all — that row simply has no delete button.
use crate::*;
use pdfs_core::control::RevisionInfo;
/// How an open Versions dialog addresses its file — the same split as
/// [`ShareTarget`](super::share_dialog::ShareTarget), for the same reason: a
/// node reached from Shared or a device folder may have no path in the primary
/// mount's namespace, and an older daemon must reject the request rather than
/// resolve an empty path to the mount root.
pub(crate) enum VersionTarget {
Path(String),
Uid(String),
}
impl VersionTarget {
fn list(&self) -> Request {
match self {
VersionTarget::Path(path) => Request::ListRevisions { path: path.clone() },
VersionTarget::Uid(uid) => Request::ListRevisionsByUid { uid: uid.clone() },
}
}
fn restore(&self, revision_id: String) -> Request {
match self {
VersionTarget::Path(path) => Request::RestoreRevision {
path: path.clone(),
revision_id,
},
VersionTarget::Uid(uid) => Request::RestoreRevisionByUid {
uid: uid.clone(),
revision_id,
},
}
}
fn delete(&self, revision_id: String) -> Request {
match self {
VersionTarget::Path(path) => Request::DeleteRevision {
path: path.clone(),
revision_id,
},
VersionTarget::Uid(uid) => Request::DeleteRevisionByUid {
uid: uid.clone(),
revision_id,
},
}
}
fn save_as(&self, revision_id: String, dest: String) -> Request {
match self {
VersionTarget::Path(path) => Request::SaveRevisionAs {
path: path.clone(),
revision_id,
dest,
},
VersionTarget::Uid(uid) => Request::SaveRevisionAsByUid {
uid: uid.clone(),
revision_id,
dest,
},
}
}
}
/// The state behind an open Versions dialog, so an action can rebuild the list
/// in place instead of tearing the dialog down.
pub(crate) struct VersionsDialog {
pub(crate) ui: Rc<Ui>,
pub(crate) target: VersionTarget,
/// The file's name, for the save dialog's suggested filename and for toasts.
pub(crate) name: String,
pub(crate) group: adw::PreferencesGroup,
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
}
/// Open the Versions dialog for a file.
pub(crate) fn open_versions_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
if !*ui.mounted.borrow() {
toast_error(ui, "Can't load versions", "Proton Drive isn't connected.");
return;
}
if entry.is_dir {
toast_error(ui, "No versions", "Folders don't have versions.");
return;
}
let target = if entry.path.is_empty() {
VersionTarget::Uid(entry.uid.clone())
} else {
VersionTarget::Path(entry_rel(ui, entry))
};
let toolbar = adw::ToolbarView::new();
let header = adw::HeaderBar::new();
header.set_title_widget(Some(&adw::WindowTitle::new("Versions", &entry.name)));
toolbar.add_top_bar(&header);
let group = adw::PreferencesGroup::builder()
.title("Version history")
.description("Proton Drive keeps earlier versions of a file until you delete them.")
.build();
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
content.set_margin_top(12);
content.set_margin_bottom(12);
content.set_margin_start(12);
content.set_margin_end(12);
content.append(&group);
let clamp = adw::Clamp::builder().child(&content).build();
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.hscrollbar_policy(gtk4::PolicyType::Never)
.child(&clamp)
.build();
toolbar.set_content(Some(&scroll));
let dialog = adw::Dialog::builder()
.title("Versions")
.content_width(520)
.content_height(520)
.child(&toolbar)
.build();
let state = Rc::new(VersionsDialog {
ui: ui.clone(),
target,
name: entry.name.clone(),
group,
rows: RefCell::new(Vec::new()),
});
versions_dialog_reload(&state);
dialog.present(ui_window(ui).as_ref());
}
/// Re-fetch the file's revisions and rebuild the rows.
pub(crate) fn versions_dialog_reload(state: &Rc<VersionsDialog>) {
let rx = spawn_request(state.ui.dirs.control_socket(), state.target.list());
let state = state.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::Revisions { items })) => repaint_versions(&state, &items),
Ok(Ok(Response::Error { message, .. })) => {
toast_error(&state.ui, "Couldn't load versions", &message)
}
_ => toast_error(
&state.ui,
"Couldn't load versions",
"The mount service didn't respond.",
),
}
});
}
/// Rebuild the version rows from a fresh listing.
fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
for row in state.rows.borrow_mut().drain(..) {
state.group.remove(&row);
}
let mut rows: Vec<gtk4::Widget> = Vec::new();
if items.is_empty() {
let row = dim_row("No earlier versions.");
state.group.add(&row);
rows.push(row.upcast());
*state.rows.borrow_mut() = rows;
return;
}
for item in items {
// The uploader's claimed plaintext size is the one that matches what the
// user sees in a listing; the storage figure counts ciphertext.
let size = item
.claimed_size
.filter(|size| *size >= 0)
.unwrap_or(item.size_on_storage)
.max(0) as u64;
let mut subtitle = format!("{} · {}", activity_time(item.created), human_bytes(size));
if let Some(email) = &item.signed_by {
subtitle.push_str(" · ");
subtitle.push_str(email);
}
let title = if item.is_active {
"Current version".to_string()
} else {
format!("Version {}", item.id)
};
let row = adw::ActionRow::builder()
.title(title)
.subtitle(subtitle)
.build();
let save = gtk4::Button::builder()
.icon_name("document-save-symbolic")
.tooltip_text("Save a copy…")
.valign(gtk4::Align::Center)
.build();
save.add_css_class("flat");
let state_save = state.clone();
let id = item.id.clone();
save.connect_clicked(move |_| prompt_save_version(&state_save, &id));
row.add_suffix(&save);
if !item.is_active {
let restore = gtk4::Button::builder()
.icon_name("edit-undo-symbolic")
.tooltip_text("Restore this version")
.valign(gtk4::Align::Center)
.build();
restore.add_css_class("flat");
let state_restore = state.clone();
let id = item.id.clone();
restore.connect_clicked(move |_| prompt_restore_version(&state_restore, &id));
row.add_suffix(&restore);
let delete = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Delete this version permanently")
.valign(gtk4::Align::Center)
.build();
delete.add_css_class("flat");
let state_delete = state.clone();
let id = item.id.clone();
delete.connect_clicked(move |_| prompt_delete_version(&state_delete, &id));
row.add_suffix(&delete);
}
state.group.add(&row);
rows.push(row.upcast());
}
*state.rows.borrow_mut() = rows;
}
/// Confirm, then restore. Confirmed because it replaces the file's content for
/// every device on the account, not only this one.
fn prompt_restore_version(state: &Rc<VersionsDialog>, revision_id: &str) {
let dialog = adw::AlertDialog::builder()
.heading("Restore version")
.body(format!(
"Make this version the current content of “{}”? The version it replaces stays in the history.",
state.name
))
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("restore", "Restore");
dialog.set_response_appearance("restore", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let window = ui_window(&state.ui);
let state = state.clone();
let revision_id = revision_id.to_string();
dialog.connect_response(None, move |_, resp| {
if resp != "restore" {
return;
}
versions_dialog_op(
&state,
state.target.restore(revision_id.clone()),
// The server applies the swap in the background, so the wording
// promises the request, not the result.
"Restoring that version",
"Couldn't restore that version",
);
});
dialog.present(window.as_ref());
}
/// Confirm, then permanently delete one revision.
fn prompt_delete_version(state: &Rc<VersionsDialog>, revision_id: &str) {
let dialog = adw::AlertDialog::builder()
.heading("Delete version")
.body("Delete this version permanently? Its content can't be recovered.")
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("delete", "Delete");
dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let window = ui_window(&state.ui);
let state = state.clone();
let revision_id = revision_id.to_string();
dialog.connect_response(None, move |_, resp| {
if resp != "delete" {
return;
}
versions_dialog_op(
&state,
state.target.delete(revision_id.clone()),
"Version deleted",
"Couldn't delete that version",
);
});
dialog.present(window.as_ref());
}
/// Ask where to write an old version, then have the daemon download it there.
///
/// The daemon writes the file, so the chosen location has to be one it can see:
/// a path from the portal-free `FileDialog` is a real filesystem path, which it
/// can.
fn prompt_save_version(state: &Rc<VersionsDialog>, revision_id: &str) {
let window = ui_window(&state.ui);
let dialog = gtk4::FileDialog::builder()
.title("Save version")
.initial_name(&state.name)
.build();
let state = state.clone();
let revision_id = revision_id.to_string();
dialog.save(window.as_ref(), gio::Cancellable::NONE, move |res| {
let Ok(file) = res else { return };
let Some(dest) = file.path().and_then(|p| p.to_str().map(str::to_string)) else {
toast_error(
&state.ui,
"Couldn't save that version",
"That location isn't a local file.",
);
return;
};
versions_dialog_op(
&state,
state.target.save_as(revision_id.clone(), dest),
"Version saved",
"Couldn't save that version",
);
});
}
/// Send one version request, toast the outcome, and refresh the list.
fn versions_dialog_op(state: &Rc<VersionsDialog>, request: Request, done: &str, failed: &str) {
let rx = spawn_request(state.ui.dirs.control_socket(), request);
let state = state.clone();
let done = done.to_string();
let failed = failed.to_string();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => {
toast(&state.ui, &done);
versions_dialog_reload(&state);
}
Ok(Ok(Response::Error { message, .. })) => toast_error(&state.ui, &failed, &message),
_ => toast_error(&state.ui, &failed, "The mount service didn't respond."),
}
});
}