mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
66 comments cited `bugs.md B<n>`, the ledger's old name; they now say `docs/BUGS.md B<n>`, and every cited number has an entry. About 90 more cited offline.md, devices.md, features.md, mount-architecture.md, plan.md and improvements.md, planning notes that were never in the repository. Those citations are removed; the comments keep their text.
3734 lines
153 KiB
Rust
3734 lines
153 KiB
Rust
//! IPC protocol between the CLI and a running mount daemon.
|
|
//!
|
|
//! The daemon listens on a Unix domain socket under the state dir; the CLI
|
|
//! (`pdfs pin` / `unpin` / `status`) connects, sends one [`Request`] as a single
|
|
//! JSON line, and reads one JSON-line [`Response`]. Keeping the wire format
|
|
//! line-delimited JSON makes the socket trivially scriptable.
|
|
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::os::unix::net::UnixStream;
|
|
use std::path::Path;
|
|
use std::time::Duration;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::cache::Pin;
|
|
use crate::error::Result;
|
|
pub use crate::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
|
|
|
/// Cap on the *write* half of a round-trip. A crashed daemon can leave its
|
|
/// listening socket in the kernel (e.g. as a `<defunct>` zombie): `connect`
|
|
/// then succeeds but no one ever replies. A short write bound trips fast when
|
|
/// the daemon is wedged before it has read anything.
|
|
const WRITE_TIMEOUT: Duration = Duration::from_secs(2);
|
|
|
|
/// Cap on the *read* half. Some requests make the daemon do real work before it
|
|
/// replies — `PhotosTimeline` downloads a page of thumbnails, `OpenFile`
|
|
/// downloads whole-file content — which easily takes more than a couple of
|
|
/// seconds. A 2s read bound timed those out and the GUI mistook the timeout for
|
|
/// "no daemon" (showing "Mount Proton Drive…" on a live mount). Give reads a
|
|
/// generous bound that still protects against a daemon that accepts but never
|
|
/// answers.
|
|
const READ_TIMEOUT: Duration = Duration::from_secs(120);
|
|
|
|
/// Cap on the read half for the requests front ends poll ([`Request::is_poll`]).
|
|
/// The daemon answers those from memory or its database, so a reply that takes
|
|
/// longer than this means it is stuck. Waiting the full [`READ_TIMEOUT`] would
|
|
/// freeze the poller's read-out for two minutes; failing fast lets it say "not
|
|
/// responding" and ask again on its next tick.
|
|
const POLL_READ_TIMEOUT: Duration = Duration::from_secs(5);
|
|
|
|
/// A command sent from the CLI to the daemon.
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
pub enum Request {
|
|
/// Report mount status (account, mountpoint, pin count).
|
|
Status,
|
|
/// Pin a file (path relative to the mountpoint, or absolute under it).
|
|
Pin { path: String },
|
|
/// Unpin a file, evicting its cached content.
|
|
Unpin { path: String },
|
|
/// List pinned files.
|
|
ListPins,
|
|
/// List a directory for the in-app file browser. `path` is
|
|
/// mountpoint-relative (empty or "." = the mount root).
|
|
ListDir { path: String },
|
|
/// Fetch a page of the photos timeline, newest first. Returns metadata only:
|
|
/// a thumbnail path comes back only for photos already in the cache, so the
|
|
/// reply never waits on the network. Front-ends ask for the thumbnails they
|
|
/// actually display with [`Request::PhotoThumbs`].
|
|
PhotosTimeline {
|
|
offset: usize,
|
|
limit: usize,
|
|
/// Restrict the page to one kind (Photos / Videos / Raw). `None` (the
|
|
/// default, and what older front-ends send) returns everything. The
|
|
/// offset is relative to the filtered timeline, so paging a single tab
|
|
/// doesn't have to walk past the other kinds.
|
|
#[serde(default)]
|
|
kind: Option<PhotoKind>,
|
|
/// Restrict the page to a `[from, to)` capture-time window (epoch
|
|
/// seconds) — the date scrubber's jump to a month. `None` spans the whole
|
|
/// timeline. Like `kind`, the offset is relative to the filtered set.
|
|
#[serde(default)]
|
|
range: Option<(i64, i64)>,
|
|
/// Restrict the page to favorited photos. Older front-ends omit it and
|
|
/// get the whole timeline, as before.
|
|
#[serde(default)]
|
|
favorites: bool,
|
|
/// Restrict the page to photos no album of ours holds — what is left to
|
|
/// file. Older front-ends omit it and get the whole timeline, as before.
|
|
#[serde(default)]
|
|
not_in_album: bool,
|
|
},
|
|
/// Every photo of one photo's group — the JPEG and the RAW of one shot, a
|
|
/// live photo and its clip. Replies with [`Response::Photos`], holding one
|
|
/// item per file in server order; a photo that stands alone comes back as a
|
|
/// single item. The lightbox uses it to switch between a shot's files.
|
|
PhotoGroup { uid: String },
|
|
/// 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.
|
|
/// `kind` scopes the counts to one tab when set.
|
|
PhotoMonths {
|
|
#[serde(default)]
|
|
kind: Option<PhotoKind>,
|
|
},
|
|
/// Photos taken on today's date in earlier years, newest first, at most
|
|
/// `limit` of them — the "On this day" strip above the timeline. Replies
|
|
/// with [`Response::Photos`]; a front-end groups them by year itself.
|
|
PhotosOnThisDay { limit: usize },
|
|
/// Sets of photos that are byte-identical copies of each other, going by
|
|
/// the server's content hash. Replies with [`Response::PhotoDuplicates`].
|
|
PhotoDuplicates,
|
|
/// Sets of shots that look alike going by their thumbnails: resized or
|
|
/// re-encoded copies, and bursts of one moment. Starts hashing the
|
|
/// thumbnails not hashed yet in the background and replies at once with
|
|
/// the sets found so far; a front-end asks again until the reply's
|
|
/// `hashing` is `None`. Replies with [`Response::PhotoSimilar`].
|
|
PhotoSimilar,
|
|
/// The towns the photos were taken in, the one with the most photos first.
|
|
/// Only photos whose uploader recorded a location count. Replies with
|
|
/// [`Response::Places`].
|
|
PhotoPlaces,
|
|
/// Fetch a page of the photos taken in one town, newest first. `id` is the
|
|
/// town's id from [`Request::PhotoPlaces`]. Replies with
|
|
/// [`Response::Photos`], like an album page.
|
|
PlacePhotos {
|
|
id: u32,
|
|
offset: usize,
|
|
limit: usize,
|
|
},
|
|
/// Every photo taken in one town with where exactly it was taken, for the
|
|
/// map's close-up. Replies with [`Response::PhotoLocations`].
|
|
PlaceLocations { id: u32 },
|
|
/// List the account's photo albums, newest activity first, including the
|
|
/// albums other people share with us. Metadata only — an album's cover
|
|
/// thumbnail is fetched with [`Request::PhotoThumbs`] like any other photo.
|
|
/// Replies with [`Response::Albums`].
|
|
PhotoAlbums,
|
|
/// Fetch a page of one album's photos, newest capture first. Same reply
|
|
/// shape as [`Request::PhotosTimeline`] ([`Response::Photos`]), so a
|
|
/// front-end paints an album with the gallery it already has. `uid` is the
|
|
/// album node's uid, as reported by [`Request::PhotoAlbums`].
|
|
AlbumPhotos {
|
|
uid: String,
|
|
offset: usize,
|
|
limit: usize,
|
|
},
|
|
/// Create an album named `name`. Replies with [`Response::AlbumCreated`].
|
|
CreateAlbum { name: String },
|
|
/// Rename the album `uid`. Replies with [`Response::Ok`].
|
|
RenameAlbum { uid: String, name: String },
|
|
/// Delete the album `uid`. Its photos stay in the timeline; with
|
|
/// `delete_photos` set, photos that exist only in the album (saved from a
|
|
/// shared album) are deleted with it, which the server otherwise refuses.
|
|
/// Replies with [`Response::Ok`].
|
|
DeleteAlbum {
|
|
uid: String,
|
|
#[serde(default)]
|
|
delete_photos: bool,
|
|
},
|
|
/// Add timeline photos to the album `uid`. Replies with
|
|
/// [`Response::AlbumChanged`].
|
|
AddToAlbum { uid: String, photos: Vec<String> },
|
|
/// Take photos out of the album `uid`. They stay in the timeline: this is
|
|
/// not a trash. Replies with [`Response::AlbumChanged`].
|
|
RemoveFromAlbum { uid: String, photos: Vec<String> },
|
|
/// Fetch thumbnails for the given photo uids, downloading the ones not
|
|
/// already cached (one batched round-trip) and replying with their on-disk
|
|
/// paths. Keep the batch small — it is served on demand, as tiles scroll in.
|
|
PhotoThumbs { uids: Vec<String> },
|
|
/// Trash photos by node uid, answered with [`Response::Trashed`].
|
|
///
|
|
/// Uid-addressed rather than path-addressed because the photos volume is not
|
|
/// part of the FUSE mount: the gallery knows a photo only by its uid, and
|
|
/// [`Request::Delete`] has no path to take for it. The nodes land in Proton
|
|
/// trash, so this stays recoverable from the Trash page.
|
|
///
|
|
/// A photo that belongs to a group takes the whole group with it: trashing
|
|
/// the JPEG of a shot and leaving its RAW behind is not what anyone means by
|
|
/// deleting that photo. With `files_only` set, exactly the listed files
|
|
/// go and their groups stay: the duplicate finder removes one copy of a
|
|
/// shot, not the shot. Older front-ends omit it and trash whole groups.
|
|
TrashNodes {
|
|
uids: Vec<String>,
|
|
#[serde(default)]
|
|
files_only: bool,
|
|
},
|
|
/// Fetch thumbnails for ordinary Drive image files shown outside the Photos
|
|
/// timeline. The modification time is the cache validity tag, so replacing
|
|
/// an image can never reuse its previous revision's thumbnail.
|
|
FileThumbs {
|
|
items: Vec<FileThumbRequest>,
|
|
/// Identifies the front-end's current listing. Moving to another page or
|
|
/// folder advances it, allowing the daemon to stop work for rows that
|
|
/// are no longer visible.
|
|
#[serde(default)]
|
|
generation: u64,
|
|
},
|
|
/// Reserve a daemon-issued generation for ordinary-file thumbnail work.
|
|
/// The daemon is the ordering authority, so GUI restarts and wall-clock
|
|
/// corrections cannot make a new listing look older than an earlier one.
|
|
ReserveFileThumbGeneration,
|
|
/// Stop ordinary-file thumbnail work belonging to older listings. Photos
|
|
/// timeline work and an explicit recursive build are deliberately separate.
|
|
CancelFileThumbs { generation: u64 },
|
|
/// Start building local thumbnails for every supported image below `path`.
|
|
/// The request returns immediately; progress is read with
|
|
/// [`Request::ThumbnailBuildStatus`].
|
|
StartThumbnailBuild { path: String },
|
|
/// Stop the current recursive ordinary-file thumbnail build, if any.
|
|
CancelThumbnailBuild,
|
|
/// Read progress for the recursive ordinary-file thumbnail build.
|
|
ThumbnailBuildStatus,
|
|
/// Whether a photos-timeline refresh is running right now. Replies with
|
|
/// [`Response::PhotosRefresh`].
|
|
///
|
|
/// [`RefreshScope::Photos`] starts the refresh and returns at once, so this
|
|
/// is how a front-end knows when the refreshed timeline is worth re-reading.
|
|
PhotosRefreshStatus,
|
|
/// 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 },
|
|
/// Import Google Photos **Takeout** archives into Proton Photos.
|
|
///
|
|
/// `archives` are absolute paths to the export's `.zip` parts on the
|
|
/// daemon's own filesystem — the whole set at once, because a photo and its
|
|
/// metadata sidecar routinely land in different parts. Photos already on the
|
|
/// account (matched by name *and* content digest) are skipped, so a library
|
|
/// partly synced by Proton Photos elsewhere does not import twice, and an
|
|
/// interrupted import resumes by simply being run again.
|
|
///
|
|
/// With `dry_run` the daemon scans and reports what it *would* do without
|
|
/// uploading anything. Otherwise it acks immediately with
|
|
/// [`Response::Ok`] and works in the background — an export is hours of
|
|
/// upload, far past any socket timeout. Progress shows up in
|
|
/// [`Request::GetQueueStatus`] as a job, and the final counts in
|
|
/// [`Request::ImportStatus`].
|
|
ImportTakeout {
|
|
archives: Vec<String>,
|
|
#[serde(default)]
|
|
dry_run: bool,
|
|
},
|
|
/// Re-date photos whose capture time is when they were *imported* rather
|
|
/// than when they were taken.
|
|
///
|
|
/// Proton has no API for editing a photo's capture time — it is sealed into
|
|
/// the revision — so the only repair is to upload the photo again with the
|
|
/// right time and trash the original. That is what this does, for photos
|
|
/// whose file name carries a date that disagrees with their stored capture
|
|
/// time by more than a day: `IMG-20230219-WA0001.jpg` filed under 2026 is
|
|
/// the signature of an import whose metadata sidecars did not match.
|
|
///
|
|
/// Favorites and album memberships are carried across; the original is
|
|
/// **trashed**, not deleted, so a bad run is recoverable from Proton's
|
|
/// trash. With `dry_run` nothing is uploaded or trashed and the report says
|
|
/// what would have been done.
|
|
///
|
|
/// Acks immediately with [`Response::Ok`] and works in the background — each
|
|
/// photo is a download plus an upload. Progress shows up in
|
|
/// [`Request::GetQueueStatus`] as a job, and the counts in
|
|
/// [`Request::RedateStatus`].
|
|
RedatePhotos {
|
|
#[serde(default)]
|
|
dry_run: bool,
|
|
/// Stop after this many photos. `None` is the whole library.
|
|
#[serde(default)]
|
|
limit: Option<usize>,
|
|
/// Only consider photos whose *stored* capture time falls in this
|
|
/// `[from, to)` window — the way to aim the repair at the days an
|
|
/// import landed on and leave the rest of the library untouched.
|
|
#[serde(default)]
|
|
range: Option<(i64, i64)>,
|
|
},
|
|
/// How the running (or last finished) re-date is doing. Replies with
|
|
/// [`Response::RedateStatus`].
|
|
RedateStatus,
|
|
/// Ask the running re-date to stop. It finishes the photo on the wire, then
|
|
/// reports `cancelled`. Replies with [`Response::Ok`].
|
|
CancelRedate,
|
|
/// How the running (or last finished) Takeout import is doing. Replies with
|
|
/// [`Response::ImportStatus`].
|
|
ImportStatus,
|
|
/// Ask the running Takeout import to stop. It finishes the photo on the wire
|
|
/// and files what it has uploaded into its albums, then reports
|
|
/// `cancelled`. Replies with [`Response::Ok`].
|
|
CancelImport,
|
|
/// 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
|
|
/// `serde_json` writes a `Vec<u8>` as an array of decimal integers — a ~5-6x
|
|
/// inflation that both peers then hold in memory at once, which turned a
|
|
/// large photo or a video into an OOM of the GUI, the daemon, or both. The
|
|
/// daemon shares a filesystem with every front-end it serves (the socket is
|
|
/// a Unix socket), so it opens and streams the file itself.
|
|
UploadPhoto {
|
|
name: String,
|
|
media_type: String,
|
|
source_path: String,
|
|
capture_time: Option<i64>,
|
|
},
|
|
/// Download a Drive file's full content into the cache; replies with the
|
|
/// on-disk path so the front-end can open it with the default app. `path`
|
|
/// is mountpoint-relative.
|
|
///
|
|
/// `uid` addresses the same node directly and is preferred when present: a
|
|
/// search hit's path comes from the metadata index, which also indexes
|
|
/// nodes the primary mount's tree does not expose (an on-demand sync
|
|
/// folder, a mirror), and walking such a path from the mount root fails
|
|
/// with "no such file or folder". Defaulted for wire-compat with clients
|
|
/// predating the field; the daemon falls back to `path` without it.
|
|
OpenFile {
|
|
path: String,
|
|
#[serde(default)]
|
|
uid: Option<String>,
|
|
},
|
|
/// Full-text search node names against the daemon's local metadata index.
|
|
/// `limit` caps the number of hits returned. `scope`, a mountpoint-relative
|
|
/// folder, restricts hits to that subtree; older front-ends omit it and
|
|
/// search everything. Replies with [`Response::SearchResults`].
|
|
Search {
|
|
query: String,
|
|
limit: usize,
|
|
#[serde(default)]
|
|
scope: Option<String>,
|
|
},
|
|
/// Search the daemon's index of *local* (non-Drive) files on this machine.
|
|
/// Independent of [`Request::Search`] so a front-end can fire both at once and
|
|
/// render whichever lands first. Replies with [`Response::LocalResults`].
|
|
SearchLocal { query: String, limit: usize },
|
|
/// Search both the Drive metadata index and the local-file index in one
|
|
/// round-trip. This is the preferred prompt API; the two legacy search
|
|
/// requests remain available for older clients. `limit` applies to each
|
|
/// source independently. `filters` is applied before limiting, so selecting
|
|
/// (for example) images cannot hide valid matches below an unfiltered top
|
|
/// `limit`. Older clients omit it and therefore search both sources and all
|
|
/// kinds. Replies with [`Response::SearchResultsV2`].
|
|
SearchV2 {
|
|
query: String,
|
|
limit: usize,
|
|
#[serde(default)]
|
|
filters: SearchFilters,
|
|
},
|
|
/// Rename a file or folder. `path` is mountpoint-relative; `new_name` is a
|
|
/// single path component (no separators). Replies with [`Response::Ok`].
|
|
Rename { path: String, new_name: String },
|
|
/// Move a file or folder into a new parent folder. Both `path` and
|
|
/// `new_parent` are mountpoint-relative, or both absolute local paths; two
|
|
/// absolute paths may lie in different locations (My files, an on-demand
|
|
/// or a mirror synced folder), and the move is still made on Proton Drive
|
|
/// without transferring content. Replies with [`Response::Ok`].
|
|
Move { path: String, new_parent: String },
|
|
/// Trash a file or folder. `path` is mountpoint-relative. Replies with
|
|
/// [`Response::Ok`].
|
|
Delete { path: String },
|
|
/// Create a new folder named `name` under the mountpoint-relative `parent`.
|
|
/// Replies with [`Response::Ok`].
|
|
CreateFolder { parent: String, name: String },
|
|
// There is deliberately no bytes-carrying single-file upload: it was
|
|
// `UploadFile { parent, name, bytes }`, and it OOMed on anything large for
|
|
// the reason described on `UploadPhoto`. `UploadPaths` covers the same case
|
|
// by path, including the one-file batch.
|
|
/// Bulk-upload local files and/or directory trees into the mountpoint-relative
|
|
/// `parent` folder. `sources` are absolute paths on the daemon's own
|
|
/// filesystem (the daemon is local): each file is uploaded, each directory is
|
|
/// recreated remotely and walked recursively. The daemon acks immediately with
|
|
/// [`Response::Ok`] and does the work in the background — a big tree far
|
|
/// outlasts the socket read timeout — so progress is observed through
|
|
/// [`Request::GetQueueStatus`] and completion through the transfer count
|
|
/// falling to zero.
|
|
UploadPaths {
|
|
parent: String,
|
|
sources: Vec<String>,
|
|
},
|
|
/// Delete all unpinned cached blobs and on-demand blocks, keeping pinned
|
|
/// files intact. Replies with [`Response::Ok`] reporting the bytes freed.
|
|
PurgeCache,
|
|
/// Retune the on-disk cache's soft byte cap at runtime (`0` = unlimited) and
|
|
/// persist it to config so the next mount keeps it. Replies with
|
|
/// [`Response::Ok`].
|
|
SetCacheBudget { bytes: u64 },
|
|
/// Cap uploads and downloads at these bytes per second (`0` = no cap),
|
|
/// live for running transfers, and persist both to config so the next
|
|
/// mount keeps them. Replies with [`Response::Ok`].
|
|
SetBandwidthLimits { upload: u64, download: u64 },
|
|
/// Report on the health of the metadata database and content cache: sizes,
|
|
/// row counts, and — when `deep` — SQLite's own integrity check. Replies
|
|
/// with [`Response::CacheReport`].
|
|
///
|
|
/// `deep` reads every page of the database, so it is opt-in: worth it when
|
|
/// diagnosing a suspected corruption, wasteful as a routine status call.
|
|
CacheInspect {
|
|
#[serde(default)]
|
|
deep: bool,
|
|
},
|
|
/// Checkpoint the write-ahead log and compact the database, replying with
|
|
/// [`Response::Ok`] reporting the bytes reclaimed.
|
|
///
|
|
/// Takes a write lock for the duration and needs room for a second copy of
|
|
/// the database, which is why it is user-invoked rather than periodic.
|
|
CacheVacuum,
|
|
/// Snapshot what the daemon is working on: in-flight transfers (active
|
|
/// uploads/downloads) and the longer jobs around them (scans, folder
|
|
/// skeletons, the local index, sync passes). Replies with
|
|
/// [`Response::Transfers`]. Cheap to poll: the daemon keeps the registry in
|
|
/// memory, so a front-end can render a live progress widget.
|
|
GetQueueStatus,
|
|
/// Report what the daemon is doing *right now*, for debugging a daemon that
|
|
/// stopped answering rather than one that is merely busy. Replies
|
|
/// [`Response::Diagnostics`].
|
|
///
|
|
/// Answered from lock-free state on purpose: it has to come back while the
|
|
/// daemon is wedged, which is the only moment anyone asks for it.
|
|
Diagnostics,
|
|
/// List what is in the account's trash. Replies with [`Response::Entries`];
|
|
/// a trashed node has no path inside the mount, so each entry carries only
|
|
/// its `uid` — the handle for [`Request::Restore`] and
|
|
/// [`Request::DeleteForever`] — and its `path` is empty.
|
|
ListTrash,
|
|
/// Restore trashed nodes, by uid, to the folders they were trashed from.
|
|
/// Replies with [`Response::Ok`].
|
|
Restore { uids: Vec<String> },
|
|
/// Permanently delete trashed nodes by uid. Irreversible: the content is
|
|
/// gone from Proton Drive, not moved. Replies with [`Response::Ok`].
|
|
DeleteForever { uids: Vec<String> },
|
|
/// Permanently delete everything in the trash. Irreversible.
|
|
/// Replies with [`Response::Ok`].
|
|
EmptyTrash,
|
|
|
|
/// Drop a cached listing so the *next* read of it re-enumerates from the
|
|
/// server. Replies with [`Response::Ok`]. This is what a front-end's Refresh
|
|
/// button raises: the daemon serves listings from its persisted cache, which
|
|
/// only notices another client's changes when its TTL lapses, so a user who
|
|
/// knows the cache is stale needs a way to say so. Cheap and idempotent —
|
|
/// it invalidates, it does not fetch.
|
|
Refresh { scope: RefreshScope },
|
|
|
|
// ---- locations --------------------------------------------------------
|
|
/// List every local Proton Drive location. Replies with
|
|
/// [`Response::Locations`].
|
|
ListLocations,
|
|
|
|
// ---- devices ----------------------------------------------------------
|
|
/// List the account's registered devices. Replies with [`Response::Devices`].
|
|
ListDevices,
|
|
/// Rename a device by its uid. Replies with [`Response::Ok`].
|
|
RenameDevice { uid: String, name: String },
|
|
/// Delete (deregister) a device by its uid. Replies with [`Response::Ok`].
|
|
DeleteDevice { uid: String },
|
|
/// Adopt an existing device as this machine's, pinning its uid in
|
|
/// `config.json` so a hostname change or reinstall re-attaches to it instead
|
|
/// of registering a duplicate. `uid: None` clears the pin.
|
|
/// Replies with [`Response::Ok`].
|
|
AdoptDevice { uid: Option<String> },
|
|
|
|
// ---- device folder sync ----------------------------------
|
|
/// Add a local folder to this machine's device, uploading its tree and
|
|
/// registering the device on first use. Replies with [`Response::Ok`].
|
|
AddSyncFolder { local_path: String },
|
|
/// List this device's synced folders. Replies with [`Response::SyncFolders`].
|
|
ListSyncFolders,
|
|
/// Remove a synced folder by id; `delete_remote` also trashes its cloud copy.
|
|
/// Replies with [`Response::Ok`].
|
|
RemoveSyncFolder { id: i64, delete_remote: bool },
|
|
/// Switch a synced folder between `mirror` and `ondemand` (Phase 3). Replies
|
|
/// with [`Response::Ok`].
|
|
SetSyncFolderMode { id: i64, mode: String },
|
|
/// Pause or resume one mirror folder's reconcile, independent of the
|
|
/// global pause. Persisted until resumed. Replies with [`Response::Ok`].
|
|
SetSyncFolderPaused { id: i64, paused: bool },
|
|
/// Force a reconcile pass: one folder by id, or all when `id` is `None`.
|
|
/// Replies with [`Response::Ok`].
|
|
SyncNow { id: Option<i64> },
|
|
/// Pause or resume syncing: queued uploads and changes stop draining and
|
|
/// mirror folders stop reconciling, while reads through the mount keep
|
|
/// working. `until` is the Unix second a pause ends by itself; `None`
|
|
/// pauses until resumed. Ignored when resuming. Replies with
|
|
/// [`Response::Ok`].
|
|
SetSyncPaused { paused: bool, until: Option<i64> },
|
|
/// List the queue of uploads and changes not yet on the remote. Replies
|
|
/// with [`Response::PendingOps`].
|
|
ListPendingOps,
|
|
/// Retry a backed-off queued op now instead of waiting out its backoff;
|
|
/// every failed op when `id` is `None`. Replies with [`Response::Ok`].
|
|
RetryPendingOp { id: Option<i64> },
|
|
/// Copy the content of a queued upload (`id` from
|
|
/// [`Request::ListPendingOps`]) to `dest`, an absolute path that must not
|
|
/// exist yet. The op stays queued. Replies with [`Response::Ok`].
|
|
ExportPendingOp { id: i64, dest: String },
|
|
/// Drop a queued change (`id` from [`Request::ListPendingOps`]) and undo
|
|
/// it locally: a node made here goes, a node Drive has goes back to what
|
|
/// Drive has, along with every other change queued on it. Its content is
|
|
/// lost unless it was exported first. Replies with [`Response::Ok`].
|
|
DiscardPendingOp { id: i64 },
|
|
/// List the `(sync-conflict …)` copies under My Files and in synced
|
|
/// folders. Replies with
|
|
/// [`Response::Conflicts`].
|
|
ListConflicts,
|
|
/// Resolve one conflict copy (`path` mountpoint-relative, or absolute for a
|
|
/// copy in a synced folder).
|
|
/// Everything removed goes to the Proton trash. Replies with
|
|
/// [`Response::Ok`].
|
|
ResolveConflict { path: String, keep: ConflictKeep },
|
|
/// List the folders under this machine's device that can be synced here,
|
|
/// each with a proposed local path. Replies with
|
|
/// [`Response::RestorableFolders`].
|
|
ListRestorableFolders,
|
|
/// Attach the given remote device folders to local paths and sync them down.
|
|
/// Replies with [`Response::Ok`].
|
|
RestoreSyncFolders { items: Vec<RestoreItem> },
|
|
/// [`Request::ListRestorableFolders`] for another registered device, by
|
|
/// uid, so its folders can be synced down to this machine. Replies with
|
|
/// [`Response::RestorableFolders`].
|
|
ListDeviceRestorableFolders { device: String },
|
|
/// [`Request::RestoreSyncFolders`] for folders under another device. A
|
|
/// separate variant rather than an optional field: an older daemon that
|
|
/// ignored the field would attach the folders under this machine's device
|
|
/// share instead. Replies with [`Response::Ok`].
|
|
RestoreDeviceFolders {
|
|
device: String,
|
|
items: Vec<RestoreItem>,
|
|
},
|
|
|
|
// ---- sharing a node ---------------------------------------------------
|
|
/// Invite `emails` (Proton and/or external addresses, auto-detected) to the
|
|
/// node at mountpoint-relative `path` at `role` ("viewer"|"editor"|"admin"),
|
|
/// with an optional email `message`. Replies with [`Response::Ok`].
|
|
ShareNode {
|
|
path: String,
|
|
emails: Vec<String>,
|
|
role: String,
|
|
message: Option<String>,
|
|
},
|
|
/// The by-uid twin of [`Request::ShareNode`], for nodes proven to belong to
|
|
/// an on-demand or mirror device location. This is a distinct variant so an
|
|
/// older daemon rejects it instead of ignoring `uid` and acting on `path`.
|
|
ShareNodeByUid {
|
|
uid: String,
|
|
emails: Vec<String>,
|
|
role: String,
|
|
message: Option<String>,
|
|
},
|
|
/// List the members, pending invitations and public link of the node at
|
|
/// mountpoint-relative `path`. Replies with [`Response::Share`].
|
|
ListShare { path: String },
|
|
/// The by-uid twin of [`Request::ListShare`].
|
|
ListShareByUid { uid: String },
|
|
/// Change the role of a share entry (member or pending invitation) on the node
|
|
/// at `path`. `id` and `kind` identify the entry (from [`Response::Share`]).
|
|
/// Replies with [`Response::Ok`].
|
|
UpdateShareRole {
|
|
path: String,
|
|
id: String,
|
|
kind: ShareEntryKind,
|
|
role: String,
|
|
},
|
|
/// The by-uid twin of [`Request::UpdateShareRole`].
|
|
UpdateShareRoleByUid {
|
|
uid: String,
|
|
id: String,
|
|
kind: ShareEntryKind,
|
|
role: String,
|
|
},
|
|
/// Remove a share entry (member, pending Proton invite, or external invite)
|
|
/// from the node at `path`. Replies with [`Response::Ok`].
|
|
RemoveShareEntry {
|
|
path: String,
|
|
id: String,
|
|
kind: ShareEntryKind,
|
|
},
|
|
/// The by-uid twin of [`Request::RemoveShareEntry`].
|
|
RemoveShareEntryByUid {
|
|
uid: String,
|
|
id: String,
|
|
kind: ShareEntryKind,
|
|
},
|
|
/// Create a public link on the node at `path`. `role` is "viewer" or "editor";
|
|
/// `password` optionally adds a custom password; `expires` is an optional Unix
|
|
/// expiry. Replies with [`Response::PublicLink`] (carrying the shareable URL).
|
|
CreatePublicLink {
|
|
path: String,
|
|
role: String,
|
|
password: Option<String>,
|
|
expires: Option<i64>,
|
|
},
|
|
/// The by-uid twin of [`Request::CreatePublicLink`].
|
|
CreatePublicLinkByUid {
|
|
uid: String,
|
|
role: String,
|
|
password: Option<String>,
|
|
expires: Option<i64>,
|
|
},
|
|
/// Remove the public link `id` from the node at `path`. Replies with
|
|
/// [`Response::Ok`].
|
|
RemovePublicLink { path: String, id: String },
|
|
/// The by-uid twin of [`Request::RemovePublicLink`].
|
|
RemovePublicLinkByUid { uid: String, id: String },
|
|
/// Stop sharing the node at `path`: remove its public link, every pending
|
|
/// invitation and every member. Each removal is attempted even if an
|
|
/// earlier one fails. Replies with [`Response::Ok`], or an error naming
|
|
/// what is still shared.
|
|
StopSharing { path: String },
|
|
/// The by-uid twin of [`Request::StopSharing`].
|
|
StopSharingByUid { uid: 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
|
|
/// [`Response::SharedByMe`]. Each item carries the share's state so the
|
|
/// front-end can render it without a follow-up per node.
|
|
ListSharedByMe,
|
|
|
|
// ---- shared with me ---------------------------------------------------
|
|
/// List nodes shared with me that I have accepted. Replies with
|
|
/// [`Response::Entries`] (each entry carries its `uid`; `path` is empty since
|
|
/// the item lives outside the mount tree).
|
|
ListSharedWithMe,
|
|
/// List the children of a folder shared with me, addressed by `uid`. Replies
|
|
/// with [`Response::Entries`]; like [`Request::ListSharedWithMe`] the entries
|
|
/// carry a `uid` and an empty `path`, since the subtree lives outside the
|
|
/// mount. Lets a front-end browse into a shared folder.
|
|
ListSharedFolder { uid: String },
|
|
/// Download a file shared with me into the content cache, addressed by `uid`.
|
|
/// Replies with [`Response::FilePath`] so the front-end can open it with the
|
|
/// default app. The by-uid twin of [`Request::OpenFile`], which can only
|
|
/// address nodes inside the mount.
|
|
OpenSharedFile { uid: String },
|
|
/// Leave a shared node by its `uid`, giving up my access. Replies with
|
|
/// [`Response::Ok`].
|
|
LeaveShared { uid: String },
|
|
|
|
// ---- incoming invitations ---------------------------------------------
|
|
/// List invitations addressed to me, pending accept or reject. Replies with
|
|
/// [`Response::Invitations`].
|
|
ListInvitations,
|
|
/// Accept the invitation `id`, gaining access to the shared node. Replies with
|
|
/// [`Response::Ok`].
|
|
AcceptInvitation { id: String },
|
|
/// Reject the invitation `id`, declining access. Replies with [`Response::Ok`].
|
|
RejectInvitation { id: String },
|
|
|
|
// ---- bookmarks --------------------------------------------------------
|
|
/// List public links saved to my account. Replies with [`Response::Bookmarks`].
|
|
ListBookmarks,
|
|
/// Save a public link `url` (optionally password-protected) as a bookmark.
|
|
/// Replies with [`Response::Ok`].
|
|
CreateBookmark {
|
|
url: String,
|
|
password: Option<String>,
|
|
},
|
|
/// Remove a saved bookmark by its `token`. Replies with [`Response::Ok`].
|
|
DeleteBookmark { token: String },
|
|
|
|
// ---- account ----------------------------------------------------------
|
|
/// Total account storage usage (used/total, all Proton products, not just
|
|
/// Drive). Replies with [`Response::AccountQuota`].
|
|
AccountQuota,
|
|
|
|
// ---- activity ---------------------------------------------------------
|
|
/// Fetch the daemon's recent activity log, newest first, capped at `limit`
|
|
/// entries. Replies with [`Response::Activity`]. The log is persisted, so it
|
|
/// survives a daemon restart: it records the mutations and transfers the
|
|
/// daemon performs (uploads, downloads, deletes, renames, shares, sync
|
|
/// passes, …), so a front-end can show a running "what happened" feed
|
|
/// without re-deriving it from anywhere.
|
|
ListActivity { limit: usize },
|
|
|
|
// ---- push -------------------------------------------------------------
|
|
/// Keep the connection open and stream [`Event`] lines instead of one
|
|
/// [`Response`]. See [`subscribe`] for the client side.
|
|
Subscribe,
|
|
}
|
|
|
|
/// Something a front-end shows that the daemon can say has changed, so the
|
|
/// front-end reads it again. Carried by [`Event::Changed`].
|
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum Topic {
|
|
/// Folder listings in the mount: something was created, renamed, moved,
|
|
/// trashed or pinned, here or on another device.
|
|
Files,
|
|
/// The photos timeline or albums.
|
|
Photos,
|
|
Trash,
|
|
/// Shares, links, invitations and saved links, in either direction.
|
|
Shares,
|
|
/// The account's computers.
|
|
Devices,
|
|
/// Synced folders, their mode and their live progress.
|
|
Locations,
|
|
/// The pending-op queue ([`Request::ListPendingOps`]).
|
|
Queue,
|
|
Conflicts,
|
|
/// The activity log gained entries.
|
|
Activity,
|
|
/// A topic from a newer daemon, which this front-end cannot act on.
|
|
#[serde(other)]
|
|
Unknown,
|
|
}
|
|
|
|
impl Topic {
|
|
/// Every topic a front-end can act on. A new subscription is told all of
|
|
/// them have changed, so it catches up on whatever happened while it was
|
|
/// not connected.
|
|
pub const ALL: [Topic; 9] = [
|
|
Topic::Files,
|
|
Topic::Photos,
|
|
Topic::Trash,
|
|
Topic::Shares,
|
|
Topic::Devices,
|
|
Topic::Locations,
|
|
Topic::Queue,
|
|
Topic::Conflicts,
|
|
Topic::Activity,
|
|
];
|
|
}
|
|
|
|
/// One line on a [`Request::Subscribe`] connection.
|
|
///
|
|
/// The two things front ends show continuously, status and transfers, come
|
|
/// with their contents, so following them costs no requests. Everything else is
|
|
/// a [`Topic`] to re-read.
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
pub enum Event {
|
|
/// A fresh [`Response::Status`]. Sent when a subscription starts and
|
|
/// whenever any of its fields changes.
|
|
Status(Box<Response>),
|
|
/// A fresh [`Response::Transfers`] snapshot. Sent when a subscription
|
|
/// starts and whenever the snapshot changes, which is continuously while
|
|
/// something moves.
|
|
Transfers {
|
|
items: Vec<TransferItem>,
|
|
jobs: Vec<JobItem>,
|
|
},
|
|
/// These topics changed. Changes close together arrive as one event.
|
|
Changed { topics: Vec<Topic> },
|
|
/// Nothing happened. Sent every [`HEARTBEAT`] so a client can tell a quiet
|
|
/// daemon from a stuck one.
|
|
Heartbeat,
|
|
}
|
|
|
|
/// How often a quiet subscription sends [`Event::Heartbeat`].
|
|
pub const HEARTBEAT: Duration = Duration::from_secs(10);
|
|
|
|
/// How long [`Subscription::next_event`] waits for a line before it gives the
|
|
/// daemon up as stuck: a few missed heartbeats.
|
|
const SUBSCRIBE_READ_TIMEOUT: Duration = Duration::from_secs(25);
|
|
|
|
/// Which kind of share entry a [`ShareEntry`] is, and which collection an
|
|
/// [`Request::UpdateShareRole`]/[`Request::RemoveShareEntry`] targets.
|
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ShareEntryKind {
|
|
/// An accepted member (identified by its membership id).
|
|
Member,
|
|
/// A pending invitation to a Proton user (identified by its invitation id).
|
|
ProtonInvite,
|
|
/// A pending invitation to a non-Proton email (identified by its invitation id).
|
|
ExternalInvite,
|
|
}
|
|
|
|
/// Which cached listing a [`Request::Refresh`] drops.
|
|
///
|
|
/// Only the listings the daemon caches need naming here — the sharing, devices
|
|
/// and activity listings are always fetched live, so a front-end refreshes those
|
|
/// by simply re-asking.
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
|
pub enum RefreshScope {
|
|
/// One folder's child listing, by mountpoint-relative path (`""` = root).
|
|
/// Only the folder itself, not its subtree: refreshing what the user is
|
|
/// looking at shouldn't re-walk everything below it.
|
|
Dir { path: String },
|
|
/// The trash listing.
|
|
Trash,
|
|
/// The photos timeline. The reply comes back as soon as the refresh has
|
|
/// *started*, not when it has finished: re-reading a large library takes
|
|
/// minutes, which is far longer than a control reply may take. Poll
|
|
/// [`Request::PhotosRefreshStatus`] to know when the new timeline is there.
|
|
///
|
|
/// A refresh normally only reads the photos the daemon has never read and
|
|
/// the ones a remote event marked changed. `full` drops that knowledge and
|
|
/// reads every photo's node again — the repair for a timeline that somehow
|
|
/// disagrees with the account, and minutes of work on a large library.
|
|
Photos {
|
|
#[serde(default)]
|
|
full: bool,
|
|
},
|
|
}
|
|
|
|
/// A registered device in a [`Response::Devices`] listing.
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct DeviceInfo {
|
|
/// Device uid — the handle for [`Request::RenameDevice`]/[`Request::DeleteDevice`].
|
|
pub uid: String,
|
|
/// Decrypted device name, or a placeholder when it could not be decrypted.
|
|
pub name: String,
|
|
/// Platform: "Windows", "MacOs" or "Linux".
|
|
pub device_type: String,
|
|
/// Last sync time, epoch seconds; `None` if it never synced.
|
|
pub last_sync: Option<i64>,
|
|
/// Whether this is the device *this* machine syncs to. Deleting it would
|
|
/// delete the cloud copy of the folders this machine is syncing, so a
|
|
/// front-end must not offer that as casually as removing another computer.
|
|
#[serde(default)]
|
|
pub this_device: bool,
|
|
/// Whether this device is the one *explicitly adopted* in `config.json`
|
|
/// ([`Request::AdoptDevice`]), as opposed to one matched by hostname. Only
|
|
/// an adopted device survives a hostname change or a reinstall.
|
|
#[serde(default)]
|
|
pub adopted: bool,
|
|
}
|
|
|
|
/// One `(sync-conflict …)` copy and the file it is a copy of (in
|
|
/// [`Response::Conflicts`]).
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
|
pub struct ConflictInfo {
|
|
/// Mountpoint-relative path of the copy, or its absolute local path when it
|
|
/// sits in a synced folder.
|
|
pub path: String,
|
|
/// Path of the file it is a copy of, in the same form as `path`.
|
|
pub original_path: String,
|
|
/// False when the original is gone and only the copy is left.
|
|
pub original_exists: bool,
|
|
pub size: u64,
|
|
/// Unix seconds.
|
|
pub modified: i64,
|
|
pub original_size: Option<u64>,
|
|
pub original_modified: Option<i64>,
|
|
/// Same size and content hash as the original: keeping either loses nothing.
|
|
pub identical: bool,
|
|
}
|
|
|
|
/// Which side of a conflict to keep (in [`Request::ResolveConflict`]).
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
|
pub enum ConflictKeep {
|
|
/// Keep the original; the copy goes to Trash.
|
|
Original,
|
|
/// Keep the copy under the original's name; the original goes to Trash.
|
|
Copy,
|
|
/// Keep both; the copy is renamed to `name`.
|
|
Both { name: String },
|
|
}
|
|
|
|
/// One queued upload or change (in [`Response::PendingOps`]).
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct PendingOpInfo {
|
|
/// Queue row id, for [`Request::RetryPendingOp`].
|
|
pub id: i64,
|
|
/// `revision`, `create`, `mkdir`, `rename` or `trash`.
|
|
pub kind: String,
|
|
/// Mountpoint-relative path of the file or folder, or its name when the
|
|
/// path is no longer known.
|
|
pub path: String,
|
|
/// Failed attempts so far.
|
|
pub attempts: i64,
|
|
/// The latest failure, when there has been one.
|
|
pub last_error: Option<String>,
|
|
/// Unix second the op was queued.
|
|
pub queued_at: i64,
|
|
/// Unix second of the next attempt; `None` while parked.
|
|
pub next_attempt_at: Option<i64>,
|
|
/// Held back until a transient file gets its final name.
|
|
pub parked: bool,
|
|
/// Failed often enough to count as stuck.
|
|
pub failing: bool,
|
|
/// Why Drive refused it, when it did; such an op needs attention at once.
|
|
#[serde(default)]
|
|
pub issue: Option<SyncIssue>,
|
|
/// Carries file content that [`Request::ExportPendingOp`] can save.
|
|
#[serde(default)]
|
|
pub exportable: bool,
|
|
}
|
|
|
|
/// Why Drive refused a queued change (in [`PendingOpInfo`]). The change stays
|
|
/// queued and keeps being retried; nothing is deleted.
|
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum SyncIssue {
|
|
/// The account's storage is full.
|
|
Quota,
|
|
/// The account may no longer change the file or its folder, for example
|
|
/// because a share was revoked.
|
|
Access,
|
|
/// The file or the folder it goes into is gone from Drive.
|
|
Missing,
|
|
/// The folder holds too many items, or is nested too deep.
|
|
Limit,
|
|
/// Drive rejected the change, for example a name it does not accept.
|
|
Rejected,
|
|
/// An issue from a newer daemon.
|
|
#[serde(other)]
|
|
Unknown,
|
|
}
|
|
|
|
impl SyncIssue {
|
|
/// The name stored in the database and sent on the wire.
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
SyncIssue::Quota => "quota",
|
|
SyncIssue::Access => "access",
|
|
SyncIssue::Missing => "missing",
|
|
SyncIssue::Limit => "limit",
|
|
SyncIssue::Rejected => "rejected",
|
|
SyncIssue::Unknown => "unknown",
|
|
}
|
|
}
|
|
|
|
/// The issue stored as `name`.
|
|
pub fn parse(name: &str) -> Self {
|
|
match name {
|
|
"quota" => SyncIssue::Quota,
|
|
"access" => SyncIssue::Access,
|
|
"missing" => SyncIssue::Missing,
|
|
"limit" => SyncIssue::Limit,
|
|
"rejected" => SyncIssue::Rejected,
|
|
_ => SyncIssue::Unknown,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One synced local folder on this machine's device (in [`Response::SyncFolders`]).
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct SyncFolderInfo {
|
|
/// Row id — the handle for [`Request::RemoveSyncFolder`]/[`Request::SetSyncFolderMode`].
|
|
pub id: i64,
|
|
/// Absolute local folder path.
|
|
pub local_path: String,
|
|
/// The uid of the folder's remote root under the device root.
|
|
pub remote_uid: String,
|
|
/// `mirror` (full local copy, two-way synced) or `ondemand` (FUSE mount).
|
|
pub mode: String,
|
|
/// A mode switch the user asked for that the daemon has queued: it applies
|
|
/// once the folder's current pass has pushed any local changes up. `None`
|
|
/// when nothing is queued. A front-end should paint the folder as already
|
|
/// heading there — the request was accepted, not rejected.
|
|
#[serde(default)]
|
|
pub pending_mode: Option<String>,
|
|
/// `idle` | `syncing` | `error` | `conflict`.
|
|
pub state: String,
|
|
/// Last successful sync, epoch seconds; `0` if never.
|
|
pub last_sync: i64,
|
|
/// What the folder's sync pass is doing right now, or `None` when no pass is
|
|
/// running. Live daemon state, not a stored column.
|
|
#[serde(default)]
|
|
pub progress: Option<SyncProgress>,
|
|
/// The folder is paused on its own (see [`Request::SetSyncFolderPaused`]).
|
|
#[serde(default)]
|
|
pub paused: bool,
|
|
}
|
|
|
|
/// A folder under this machine's device offered for restore (in
|
|
/// [`Response::RestorableFolders`]).
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct RestorableFolder {
|
|
/// Uid of the remote folder — the handle for [`RestoreItem`].
|
|
pub remote_uid: String,
|
|
/// Its remote name.
|
|
pub name: String,
|
|
/// Where the daemon *proposes* to put it: the path the profile recorded when
|
|
/// that path makes sense on this machine, else `~/<name>`. A front-end shows
|
|
/// this as an editable default, never applies it silently.
|
|
pub local_path: String,
|
|
/// Proposed mode, from the profile; `mirror` when unknown.
|
|
pub mode: String,
|
|
/// Already synced on this machine, so restoring it again would be a no-op.
|
|
/// Listed anyway so the picker shows the whole device.
|
|
#[serde(default)]
|
|
pub already_synced: bool,
|
|
}
|
|
|
|
/// One folder to restore, in [`Request::RestoreSyncFolders`].
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct RestoreItem {
|
|
pub remote_uid: String,
|
|
/// Absolute local path to sync it to; created if missing.
|
|
pub local_path: String,
|
|
/// `mirror` or `ondemand`.
|
|
pub mode: String,
|
|
}
|
|
|
|
/// Which stage a running sync pass is in, in a [`SyncProgress`].
|
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum SyncPhase {
|
|
/// Walking the local tree, the remote tree and the stored baseline to work
|
|
/// out what changed. `done` counts the items checked so far; `total` is how
|
|
/// many the last pass saw, so it is an estimate the walk can overshoot.
|
|
Scanning,
|
|
/// Applying the diff: creating folders, uploading, downloading, deleting.
|
|
Applying,
|
|
}
|
|
|
|
/// A snapshot of a sync pass in flight (in [`SyncFolderInfo::progress`]), so a
|
|
/// front-end can say what the daemon is doing rather than just "syncing".
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct SyncProgress {
|
|
pub phase: SyncPhase,
|
|
/// Items checked ([`SyncPhase::Scanning`]) or applied ([`SyncPhase::Applying`])
|
|
/// so far this pass.
|
|
pub done: usize,
|
|
/// How many items `done` is counting towards. Neither phase can fix this up
|
|
/// front, so it moves: while scanning it is the size of the last pass's
|
|
/// baseline — an estimate the walk may overshoot when the folder has grown —
|
|
/// and while applying it *grows*, because paths are classified depth by depth
|
|
/// (a folder must exist remotely before its children can be queued). `0` means
|
|
/// no estimate exists (a folder that has never synced), i.e. indeterminate.
|
|
pub total: usize,
|
|
/// The name of an item currently being applied, or empty between items.
|
|
/// Several run at once; this is just the most recently started.
|
|
pub current: String,
|
|
}
|
|
|
|
/// One member or pending invitation on a node's share (in [`Response::Share`]).
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct ShareEntry {
|
|
/// Membership id (members) or invitation id (invitations) — the handle for
|
|
/// [`Request::UpdateShareRole`]/[`Request::RemoveShareEntry`].
|
|
pub id: String,
|
|
/// The member's / invitee's email address.
|
|
pub email: String,
|
|
/// Role: "viewer", "editor", "admin" or "inherited".
|
|
pub role: String,
|
|
/// Whether this is a member, a pending Proton invite, or an external invite.
|
|
pub kind: ShareEntryKind,
|
|
}
|
|
|
|
/// A node's public link (in [`Response::Share`] / [`Response::PublicLink`]).
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
|
pub struct PublicLinkInfo {
|
|
/// Public-link id — the handle for [`Request::RemovePublicLink`].
|
|
pub id: String,
|
|
/// The shareable URL including the `#password` fragment, when known (always on
|
|
/// creation; absent when only listed).
|
|
pub url: Option<String>,
|
|
/// Role granted to anyone with the link ("viewer" or "editor").
|
|
pub role: String,
|
|
/// Expiry, epoch seconds, if set.
|
|
pub expires: Option<i64>,
|
|
/// Whether a custom password additionally protects the link.
|
|
pub has_password: bool,
|
|
/// When the link was made, epoch seconds; 0 when unknown.
|
|
#[serde(default)]
|
|
pub created: i64,
|
|
}
|
|
|
|
/// One invitation addressed to me (in [`Response::Invitations`]).
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct InvitationInfo {
|
|
/// Invitation id — the handle for [`Request::AcceptInvitation`]/[`Request::RejectInvitation`].
|
|
pub id: String,
|
|
/// The email of the user who shared the item.
|
|
pub inviter_email: String,
|
|
/// The shared item's decrypted name, when available.
|
|
pub name: Option<String>,
|
|
/// Whether the shared item is a folder.
|
|
pub is_dir: bool,
|
|
}
|
|
|
|
/// One saved public link (in [`Response::Bookmarks`]).
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct BookmarkInfo {
|
|
/// Bookmark token — the handle for [`Request::DeleteBookmark`].
|
|
pub token: String,
|
|
/// The full public URL including the `#password` fragment.
|
|
pub url: String,
|
|
/// The bookmarked item's decrypted name, when available.
|
|
pub name: Option<String>,
|
|
/// Whether the bookmarked item is a folder.
|
|
pub is_dir: bool,
|
|
}
|
|
|
|
/// One node I have shared with others (in [`Response::SharedByMe`]). Summarizes
|
|
/// the share's state so the "Shared" view renders in one pass: how many people
|
|
/// have access, how many invitations are still pending, and the public link if
|
|
/// the node has one.
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
|
pub struct SharedItem {
|
|
/// Node uid in `volume~link` form — the handle for opening the node's share
|
|
/// (via its mount path) or its details.
|
|
pub uid: String,
|
|
/// The shared node's decrypted name.
|
|
pub name: String,
|
|
pub is_dir: bool,
|
|
/// Modification time used to validate an image thumbnail. Older daemons
|
|
/// omit it, in which case clients use `0` as a conservative fallback tag.
|
|
#[serde(default)]
|
|
pub modified: i64,
|
|
/// Mountpoint-relative path, when the daemon can resolve it (the node lives in
|
|
/// my own tree). Empty when the path is unknown.
|
|
#[serde(default)]
|
|
pub path: String,
|
|
/// Accepted members (people who already have access), excluding me.
|
|
pub member_count: usize,
|
|
/// Pending invitations (Proton + external) not yet accepted.
|
|
pub invite_count: usize,
|
|
/// The node's public link, if it has one.
|
|
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)]
|
|
pub enum ActivityKind {
|
|
Upload,
|
|
Download,
|
|
/// A whole sync pass over one folder, summarising what it moved.
|
|
Sync,
|
|
Rename,
|
|
Move,
|
|
CreateFolder,
|
|
Trash,
|
|
Restore,
|
|
DeleteForever,
|
|
EmptyTrash,
|
|
Share,
|
|
PublicLink,
|
|
Unshare,
|
|
/// A `(sync-conflict …)` copy that needs the user's attention: it diverges
|
|
/// from the live file (or has no sibling), so the auto-sweep left it in place
|
|
/// rather than removing it. An auto-*removed* identical copy is logged as a
|
|
/// [`ActivityKind::Trash`] instead, since that is what happened to it.
|
|
Conflict,
|
|
}
|
|
|
|
/// One line in the daemon's activity log (in [`Response::Activity`]). Newest
|
|
/// first. Records a mutation or transfer the daemon performed, with enough
|
|
/// context to read as a sentence: "Uploaded report.pdf to /docs".
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
|
pub struct ActivityEntry {
|
|
/// When it happened, epoch seconds.
|
|
pub time: i64,
|
|
pub kind: ActivityKind,
|
|
/// The primary item's name (a file/folder name, or a count like "3 items").
|
|
pub target: String,
|
|
/// Extra context: a destination path, a new name, an invitee, an error
|
|
/// message. Empty when there is nothing to add.
|
|
#[serde(default)]
|
|
pub detail: String,
|
|
/// Whether the operation succeeded. A failed entry still appears, so the log
|
|
/// shows what was attempted.
|
|
pub ok: bool,
|
|
}
|
|
|
|
/// Which way an active transfer is moving, in a [`TransferItem`].
|
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum TransferDirection {
|
|
Download,
|
|
Upload,
|
|
}
|
|
|
|
/// One long-running daemon job in a [`Response::Transfers`] snapshot: work that
|
|
/// takes long enough to need reporting but doesn't move bytes over the wire —
|
|
/// walking a local tree, building a remote folder skeleton, indexing `$HOME`.
|
|
/// Byte-moving work is a [`TransferItem`] instead.
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct JobItem {
|
|
/// What the job is, as a front-end would title it ("Uploading files").
|
|
pub title: String,
|
|
/// What it is doing right now ("Scanning Photos/2024"), or empty.
|
|
pub detail: String,
|
|
/// Steps finished so far.
|
|
pub done: u64,
|
|
/// Steps known to need doing, or `0` when unknown (indeterminate progress).
|
|
/// May *grow* mid-job as more work is discovered.
|
|
pub total: u64,
|
|
}
|
|
|
|
/// One in-flight transfer in a [`Response::Transfers`] snapshot.
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct TransferItem {
|
|
/// Node uid in `volume~link` form (empty for an upload whose uid isn't known
|
|
/// until the draft is sealed).
|
|
pub uid: String,
|
|
/// File name being transferred.
|
|
pub name: String,
|
|
pub direction: TransferDirection,
|
|
/// Bytes moved so far.
|
|
pub bytes_completed: u64,
|
|
/// Total bytes expected, or `0` when unknown (indeterminate progress).
|
|
pub bytes_total: u64,
|
|
/// Average throughput since the transfer began, bytes per second.
|
|
pub speed_bytes_sec: u64,
|
|
}
|
|
|
|
/// One entry in a [`Request::ListDir`] listing.
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
|
|
pub struct DirEntry {
|
|
/// Decrypted node name.
|
|
pub name: String,
|
|
pub is_dir: bool,
|
|
/// Plaintext size in bytes (0 for folders).
|
|
pub size: u64,
|
|
/// Modification time, epoch seconds.
|
|
pub modified: i64,
|
|
/// Whether the file is pinned to this device.
|
|
pub pinned: bool,
|
|
/// Whether the file's full content is present in the local cache (a current,
|
|
/// non-stale blob). Always false for folders. Defaulted for wire-compat with
|
|
/// clients/daemons predating the field.
|
|
#[serde(default)]
|
|
pub cached: bool,
|
|
/// Node uid in `volume~link` form, for follow-up requests.
|
|
pub uid: String,
|
|
/// Full mountpoint-relative path. Empty for a [`Request::ListDir`] listing
|
|
/// (the entry lives in the requested directory, so the caller derives the
|
|
/// path by joining its name) and for a [`Request::ListTrash`] listing (a
|
|
/// trashed node has no path inside the mount at all); populated when an entry
|
|
/// can live anywhere in the tree, as for search hits rendered through the
|
|
/// browser.
|
|
#[serde(default)]
|
|
pub path: String,
|
|
/// My role on this node when it is reached through a share I accepted —
|
|
/// `"viewer"`, `"editor"`, `"admin"`, or empty for content I own (where the
|
|
/// question does not arise) and for a share whose role the API did not
|
|
/// report. Defaulted for wire-compat with clients/daemons predating it.
|
|
#[serde(default)]
|
|
pub role: String,
|
|
/// Who shared this node with me, as the invitation claims it — empty for
|
|
/// content I own, for a node reached *inside* a shared folder (only the share
|
|
/// root carries the invitation), and when the API did not say. Trust it only
|
|
/// as far as [`shared_by_unverified`](Self::shared_by_unverified) allows.
|
|
/// Defaulted for wire-compat with clients/daemons predating it.
|
|
#[serde(default)]
|
|
pub shared_by: String,
|
|
/// When it was shared with me, epoch seconds; 0 when unknown.
|
|
#[serde(default)]
|
|
pub shared_at: i64,
|
|
/// The invitation's signature did not check out against `shared_by`'s keys,
|
|
/// so the name may be forged. False when it verified *and* when it could not
|
|
/// be checked at all (no signature, no keys): only an actual mismatch is a
|
|
/// warning worth showing.
|
|
#[serde(default)]
|
|
pub shared_by_unverified: bool,
|
|
/// When a [`Request::ListTrash`] entry went to the trash, epoch seconds; 0
|
|
/// when unknown, and for anything not in the trash. The API does not report
|
|
/// it to this client, so the daemon notes when it first saw the node there.
|
|
#[serde(default)]
|
|
pub trashed_at: i64,
|
|
/// The folder a [`Request::ListTrash`] entry goes back to on restore, as a
|
|
/// mountpoint-relative path (`""` for the root). `None` when that folder is
|
|
/// not known, and for anything not in the trash.
|
|
#[serde(default)]
|
|
pub trashed_from: Option<String>,
|
|
/// Why Drive refused a change queued on the node, for a [`Request::ListDir`]
|
|
/// entry; `None` otherwise.
|
|
#[serde(default)]
|
|
pub issue: Option<SyncIssue>,
|
|
}
|
|
|
|
/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
|
|
/// full mountpoint-relative `path` (a hit can live anywhere in the tree), so the
|
|
/// front-end can navigate to or open it directly.
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct SearchHit {
|
|
pub name: String,
|
|
/// Mountpoint-relative path (`/`-joined, no leading slash).
|
|
pub path: String,
|
|
pub is_dir: bool,
|
|
pub size: u64,
|
|
pub modified: i64,
|
|
pub pinned: bool,
|
|
/// Whether a valid copy of the content is in the local cache. Legacy
|
|
/// daemons omit it; clients then treat the file as online-only.
|
|
#[serde(default)]
|
|
pub cached: bool,
|
|
/// Node uid in `volume~link` form.
|
|
pub uid: String,
|
|
/// Absolute path through the most specific active on-demand mount, when the
|
|
/// node is covered by one. Clients should prefer this for folders and
|
|
/// streamable media, and fall back to `mountpoint/path` when absent.
|
|
#[serde(default)]
|
|
pub mounted_path: Option<String>,
|
|
/// Relevance assigned by the daemon. Higher scores sort first. Legacy
|
|
/// daemons omit it, in which case clients retain the daemon's result order.
|
|
#[serde(default)]
|
|
pub score: i64,
|
|
}
|
|
|
|
/// One hit in a [`Request::SearchLocal`] result: a file on this machine, outside
|
|
/// Proton Drive. Unlike [`SearchHit`] there is no uid or pin state — the file is
|
|
/// already local, so the front-end opens `path` directly.
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct LocalHit {
|
|
pub name: String,
|
|
/// Absolute path on this machine.
|
|
pub path: String,
|
|
pub is_dir: bool,
|
|
pub size: u64,
|
|
/// Modification time, epoch seconds.
|
|
pub modified: i64,
|
|
/// Relevance assigned by the daemon. Higher scores sort first. Legacy
|
|
/// daemons omit it, in which case clients retain the daemon's result order.
|
|
#[serde(default)]
|
|
pub score: i64,
|
|
}
|
|
|
|
/// A searchable corpus in [`Request::SearchV2`]. Kept explicit on the wire so
|
|
/// future sources can be added without multiplying request variants.
|
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum SearchSource {
|
|
Drive,
|
|
Local,
|
|
}
|
|
|
|
/// Coarse result kind used by the prompt's facets. Classification belongs in
|
|
/// core/the daemon and must happen before the per-source limit is applied.
|
|
#[derive(Serialize, Deserialize, Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum SearchKind {
|
|
#[default]
|
|
All,
|
|
Folders,
|
|
Documents,
|
|
Images,
|
|
Media,
|
|
}
|
|
|
|
impl SearchKind {
|
|
/// Classify a result before the caller applies its final limit. This stays
|
|
/// beside the wire enum so every front end and both search corpora agree.
|
|
pub fn accepts(self, name: &str, is_dir: bool) -> bool {
|
|
if self == Self::All {
|
|
return true;
|
|
}
|
|
if self == Self::Folders {
|
|
return is_dir;
|
|
}
|
|
if is_dir {
|
|
return false;
|
|
}
|
|
let extension = std::path::Path::new(name)
|
|
.extension()
|
|
.and_then(|value| value.to_str())
|
|
.unwrap_or_default()
|
|
.to_ascii_lowercase();
|
|
match self {
|
|
Self::Documents => matches!(
|
|
extension.as_str(),
|
|
"pdf"
|
|
| "doc"
|
|
| "docx"
|
|
| "odt"
|
|
| "rtf"
|
|
| "txt"
|
|
| "md"
|
|
| "xls"
|
|
| "xlsx"
|
|
| "ods"
|
|
| "csv"
|
|
| "ppt"
|
|
| "pptx"
|
|
| "odp"
|
|
| "epub"
|
|
),
|
|
Self::Images => matches!(
|
|
extension.as_str(),
|
|
"png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "svg" | "avif" | "heic" | "tiff"
|
|
),
|
|
Self::Media => {
|
|
PhotoKind::classify(Some(name), None) == PhotoKind::Video
|
|
|| matches!(
|
|
extension.as_str(),
|
|
"mp3" | "flac" | "wav" | "ogg" | "opus" | "m4a" | "aac"
|
|
)
|
|
}
|
|
Self::All | Self::Folders => unreachable!(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Optional constraints for a unified search. The default deliberately means
|
|
/// the original SearchV2 behaviour, preserving old JSON requests.
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
|
pub struct SearchFilters {
|
|
/// Sources to query. An explicit empty list searches neither source.
|
|
#[serde(default = "default_search_sources")]
|
|
pub sources: Vec<SearchSource>,
|
|
#[serde(default)]
|
|
pub kind: SearchKind,
|
|
}
|
|
|
|
impl Default for SearchFilters {
|
|
fn default() -> Self {
|
|
Self {
|
|
sources: default_search_sources(),
|
|
kind: SearchKind::All,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_search_sources() -> Vec<SearchSource> {
|
|
vec![SearchSource::Drive, SearchSource::Local]
|
|
}
|
|
|
|
/// What kind of media a timeline entry is, so the Photos page can split into
|
|
/// Photos / Videos / Raw tabs. Derived from a photo's media type when the daemon
|
|
/// has resolved it, falling back to its file-name extension (see
|
|
/// [`PhotoKind::classify`]).
|
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum PhotoKind {
|
|
/// A normal, directly viewable still image (JPEG, PNG, HEIC, …).
|
|
Photo,
|
|
/// A video clip (mp4, mkv, mov, …).
|
|
Video,
|
|
/// A camera raw file (CR2, NEF, ARW, DNG, …) — an image, but one that needs
|
|
/// developing and is worth separating from ready-to-view photos.
|
|
Raw,
|
|
}
|
|
|
|
/// File-name extensions that denote a video, matched case-insensitively.
|
|
const VIDEO_EXTS: &[&str] = &[
|
|
"mkv", "mp4", "mov", "avi", "webm", "m4v", "flv", "wmv", "mpg", "mpeg", "ts", "3gp", "m2ts",
|
|
"mts", "ogv",
|
|
];
|
|
|
|
/// File-name extensions that denote a camera raw. The server media type for
|
|
/// these is frequently a generic `application/octet-stream`, so the extension is
|
|
/// the authoritative signal.
|
|
const RAW_EXTS: &[&str] = &[
|
|
"cr2", "cr3", "nef", "nrw", "arw", "srf", "sr2", "dng", "raf", "orf", "rw2", "srw", "pef",
|
|
"raw", "rwl", "iiq", "3fr", "dcr", "kdc", "mrw", "x3f",
|
|
];
|
|
|
|
/// File-name extensions that denote a ready-to-view still photo. Listed so a
|
|
/// known image name classifies as a photo outright, without deferring to a media
|
|
/// type that might disagree.
|
|
const PHOTO_EXTS: &[&str] = &[
|
|
"jpg", "jpeg", "png", "gif", "webp", "heic", "heif", "avif", "bmp", "tif", "tiff",
|
|
];
|
|
|
|
impl PhotoKind {
|
|
/// Classify a photo from what the daemon knows about it. A recognised
|
|
/// file-name extension is authoritative — it is reliable even before the
|
|
/// node's media type is resolved, and for raw files the media type is often a
|
|
/// useless generic — so the media type is only consulted for names that carry
|
|
/// no extension we know. Anything still unresolved is a still photo.
|
|
pub fn classify(name: Option<&str>, media_type: Option<&str>) -> PhotoKind {
|
|
if let Some(ext) = name
|
|
.and_then(|n| n.rsplit_once('.'))
|
|
.map(|(_, e)| e.to_ascii_lowercase())
|
|
{
|
|
if RAW_EXTS.contains(&ext.as_str()) {
|
|
return PhotoKind::Raw;
|
|
}
|
|
if VIDEO_EXTS.contains(&ext.as_str()) {
|
|
return PhotoKind::Video;
|
|
}
|
|
if PHOTO_EXTS.contains(&ext.as_str()) {
|
|
return PhotoKind::Photo;
|
|
}
|
|
}
|
|
if let Some(mt) = media_type
|
|
&& mt.starts_with("video/")
|
|
{
|
|
return PhotoKind::Video;
|
|
}
|
|
PhotoKind::Photo
|
|
}
|
|
|
|
/// The stable integer this kind is persisted as (see the `kind` column of the
|
|
/// `photos` table). Chosen once and never reordered.
|
|
pub fn as_i64(self) -> i64 {
|
|
match self {
|
|
PhotoKind::Photo => 0,
|
|
PhotoKind::Video => 1,
|
|
PhotoKind::Raw => 2,
|
|
}
|
|
}
|
|
|
|
/// Inverse of [`PhotoKind::as_i64`]; any unrecognised value reads as a still
|
|
/// photo, the safe default for a tab that would otherwise show nothing.
|
|
pub fn from_i64(v: i64) -> PhotoKind {
|
|
match v {
|
|
1 => PhotoKind::Video,
|
|
2 => PhotoKind::Raw,
|
|
_ => PhotoKind::Photo,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One month the timeline spans, with how many photos it holds — a tick on the
|
|
/// date scrubber (reply to [`Request::PhotoMonths`]).
|
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct PhotoMonth {
|
|
/// Local-time calendar year, e.g. `2026`.
|
|
pub year: i32,
|
|
/// Local-time month, `1..=12`.
|
|
pub month: i32,
|
|
/// Photos captured in that month (within the requested kind, if any).
|
|
pub count: usize,
|
|
}
|
|
|
|
/// How a photo re-date is going, or how the last one ended
|
|
/// ([`Response::RedateStatus`]). Cumulative for the run, so the same shape
|
|
/// serves progress and the final report.
|
|
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct RedateSummary {
|
|
/// Photos examined.
|
|
pub examined: usize,
|
|
/// Photos whose name implies a capture time more than a day from the one
|
|
/// they carry — what the run would (or did) repair.
|
|
pub candidates: usize,
|
|
/// Photos re-uploaded with the right capture time, their original trashed.
|
|
pub redated: usize,
|
|
/// Photos that could not be downloaded, re-uploaded, or trashed. The
|
|
/// original is left alone in every failure case.
|
|
pub failed: usize,
|
|
/// Album memberships re-created on the new copies.
|
|
pub album_links: usize,
|
|
/// Bytes uploaded.
|
|
pub bytes: u64,
|
|
/// True when the run stopped early because it was cancelled.
|
|
pub cancelled: bool,
|
|
/// True when nothing was actually uploaded or trashed.
|
|
pub dry_run: bool,
|
|
/// Up to a handful of examples, as `(name, stored capture time, the time the
|
|
/// name implies)` — what a dry run is read for.
|
|
#[serde(default)]
|
|
pub samples: Vec<(String, i64, i64)>,
|
|
}
|
|
|
|
/// How a Google Photos Takeout import is going, or how the last one ended
|
|
/// ([`Response::ImportStatus`]).
|
|
///
|
|
/// The counts are cumulative for the run, so the same shape serves progress and
|
|
/// the final report. `found` counts *distinct* photos: Google stores a separate
|
|
/// copy of a photo in each album folder it appears in, and those fold into one
|
|
/// upload that joins several albums.
|
|
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct ImportSummary {
|
|
/// Distinct photos the export holds.
|
|
pub found: usize,
|
|
/// Photos uploaded by this run.
|
|
pub uploaded: usize,
|
|
/// Photos already on the account, matched by name *and* content digest.
|
|
pub duplicates: usize,
|
|
/// Photos that could not be read or uploaded.
|
|
pub failed: usize,
|
|
/// Photos in the export's trash bucket, which are never imported.
|
|
pub skipped_trashed: usize,
|
|
/// Albums created by this run.
|
|
pub albums_created: usize,
|
|
/// Album memberships added by this run.
|
|
pub album_links: usize,
|
|
/// Bytes uploaded.
|
|
pub bytes: u64,
|
|
/// True when the run stopped early because it was cancelled.
|
|
pub cancelled: bool,
|
|
}
|
|
|
|
/// One album in a [`Response::Albums`] listing.
|
|
///
|
|
/// An album is a folder node on the photos volume carrying album properties, so
|
|
/// it has an ordinary name; everything else here comes from those properties.
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct AlbumInfo {
|
|
/// Album node uid in `volume~link` form — what [`Request::AlbumPhotos`]
|
|
/// takes.
|
|
pub uid: String,
|
|
pub name: String,
|
|
/// How many photos the server says the album holds.
|
|
pub photo_count: usize,
|
|
/// The photo shown as the album's cover, when it has one. Its thumbnail is
|
|
/// fetched with [`Request::PhotoThumbs`] like any other photo.
|
|
pub cover_uid: Option<String>,
|
|
/// Epoch seconds of the last change to the album's contents, when the server
|
|
/// reports one. The listing is ordered by it, newest first.
|
|
pub last_activity: Option<i64>,
|
|
/// True when this album lives on someone else's photos volume — it is shared
|
|
/// with us rather than ours.
|
|
pub shared: bool,
|
|
}
|
|
|
|
/// One town in a [`Response::Places`] listing.
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct PlaceInfo {
|
|
/// The town's GeoNames id — what [`Request::PlacePhotos`] takes.
|
|
pub id: u32,
|
|
/// The town's name, as the town itself spells it.
|
|
pub name: String,
|
|
/// ISO 3166-1 alpha-2 code of the town's country.
|
|
pub country: String,
|
|
/// Where the town is, in decimal degrees, for the map. Zero from a daemon
|
|
/// older than the map.
|
|
#[serde(default)]
|
|
pub latitude: f64,
|
|
#[serde(default)]
|
|
pub longitude: f64,
|
|
/// How many photos were taken there, counting a RAW+JPEG shot once.
|
|
pub photo_count: usize,
|
|
/// The newest photo taken there, shown as the place's cover.
|
|
pub cover: PhotoItem,
|
|
}
|
|
|
|
/// One photo and where it was taken, in decimal degrees. Part of
|
|
/// [`Response::PhotoLocations`].
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct PhotoLocation {
|
|
pub photo: PhotoItem,
|
|
pub latitude: f64,
|
|
pub longitude: f64,
|
|
}
|
|
|
|
/// How many of the timeline's photos a running refresh has read the metadata
|
|
/// (and so the location) of, out of how many. Part of [`Response::Places`],
|
|
/// and of [`Response::PhotoSimilar`] for how many shots have been hashed.
|
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct MappingProgress {
|
|
pub done: usize,
|
|
pub total: usize,
|
|
}
|
|
|
|
/// One photo in a [`Request::PhotosTimeline`] page.
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct PhotoItem {
|
|
/// Node uid in `volume~link` form.
|
|
pub uid: String,
|
|
/// Capture time, epoch seconds (the timeline is newest-first).
|
|
pub capture_time: i64,
|
|
/// On-disk path to the cached thumbnail, when one is *already* cached. A
|
|
/// `None` here means "not fetched yet", not "has no thumbnail" — ask for it
|
|
/// with [`Request::PhotoThumbs`].
|
|
pub thumb_path: Option<String>,
|
|
/// File name, when the daemon knows it.
|
|
pub name: Option<String>,
|
|
/// Aspect ratio (w/h), remembered by the daemon from the last time this
|
|
/// photo's thumbnail was decoded. Lets the gallery justify its rows correctly
|
|
/// on the first frame instead of guessing and reflowing as images land.
|
|
pub ratio: Option<f64>,
|
|
/// True when this photo can never be given a thumbnail — the server has none
|
|
/// and its bytes could not be decoded locally. The tile shows a placeholder
|
|
/// rather than waiting for an image that will never come.
|
|
pub no_thumb: bool,
|
|
/// Which Photos-page tab this entry belongs to. Older daemons that predate
|
|
/// 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 favorited. Older daemons omit it, which a front-end
|
|
/// reads as "not favorited" — the same thing it would show for a photo whose
|
|
/// tags it has not learned yet.
|
|
#[serde(default)]
|
|
pub favorite: bool,
|
|
/// How many files this entry stands for: a shot stored as a RAW and a JPEG
|
|
/// is one entry and two files. Older daemons omit it, and `0` reads the same
|
|
/// as `1` — one file, nothing to indicate.
|
|
#[serde(default)]
|
|
pub group_size: u32,
|
|
/// Whether one of those files is a camera raw. The tile badges it, because
|
|
/// the JPEG it shows is not the whole of what is stored.
|
|
#[serde(default)]
|
|
pub has_raw: bool,
|
|
}
|
|
|
|
/// A daemon too old to classify a timeline entry is assumed to have served a
|
|
/// still photo — that was the only kind the Photos page showed before the split.
|
|
fn default_photo_kind() -> PhotoKind {
|
|
PhotoKind::Photo
|
|
}
|
|
|
|
/// One node a [`Request::TrashNodes`] batch could not trash, and why.
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct TrashFailure {
|
|
pub uid: String,
|
|
pub message: String,
|
|
}
|
|
|
|
/// One thumbnail in a [`Response::Thumbs`] batch.
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct PhotoThumb {
|
|
pub uid: String,
|
|
/// On-disk path, or `None` when there is no thumbnail to serve *yet*.
|
|
pub path: Option<String>,
|
|
/// True when the daemon is making this thumbnail itself, because the server
|
|
/// has none: the photo's full file is downloading and will be scaled when it
|
|
/// lands. A `None` path with `pending` set means "ask again shortly"; a `None`
|
|
/// path *without* it means the photo can never have a thumbnail, and a
|
|
/// front-end should stop asking.
|
|
///
|
|
/// Generation is not made to block the reply: one 20 MB camera photo takes
|
|
/// far longer to fetch than the whole rest of a batch, and holding the batch
|
|
/// for it would leave a screenful of ready thumbnails unpainted.
|
|
pub pending: bool,
|
|
}
|
|
|
|
/// One ordinary Drive file whose thumbnail a front-end wants to paint.
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct FileThumbRequest {
|
|
/// Node uid in `volume~link` form.
|
|
pub uid: String,
|
|
/// File modification time, used as the thumbnail cache validity tag.
|
|
pub modified: i64,
|
|
/// Original Drive name. Local RAW-preview extraction uses the extension as a
|
|
/// format hint; defaulted for wire compatibility with older front-ends.
|
|
#[serde(default)]
|
|
pub name: String,
|
|
}
|
|
|
|
/// Whether a Drive name is a raster image that the built-in decoder can read,
|
|
/// or a concrete camera RAW format whose embedded preview exiftool can extract.
|
|
/// Keep this list shared by the daemon and every GUI surface so a visible tile
|
|
/// is never advertised without a matching generation path.
|
|
pub fn is_thumbnail_image_name(name: &str) -> bool {
|
|
let Some((_, extension)) = name.rsplit_once('.') else {
|
|
return false;
|
|
};
|
|
let extension = extension.to_ascii_lowercase();
|
|
STANDARD_THUMBNAIL_EXTENSIONS.contains(&extension.as_str())
|
|
|| RAW_THUMBNAIL_EXTENSIONS.contains(&extension.as_str())
|
|
}
|
|
|
|
/// Whether `name` is one of the concrete RAW formats handled by the exiftool
|
|
/// embedded-preview path. Deliberately excludes generic/legacy grab-bags whose
|
|
/// preview availability is not dependable.
|
|
pub fn is_raw_image_name(name: &str) -> bool {
|
|
let Some((_, extension)) = name.rsplit_once('.') else {
|
|
return false;
|
|
};
|
|
let extension = extension.to_ascii_lowercase();
|
|
RAW_THUMBNAIL_EXTENSIONS.contains(&extension.as_str())
|
|
}
|
|
|
|
const STANDARD_THUMBNAIL_EXTENSIONS: &[&str] =
|
|
&["bmp", "gif", "jpeg", "jpg", "png", "tif", "tiff", "webp"];
|
|
|
|
/// Concrete camera RAW formats with embedded previews handled by exiftool.
|
|
/// This is the single source used by both RAW classification and the broader
|
|
/// thumbnail capability check.
|
|
const RAW_THUMBNAIL_EXTENSIONS: &[&str] = &[
|
|
"arw", "cr2", "cr3", "crw", "dng", "k25", "kdc", "mrw", "nef", "nrw", "orf", "pef", "raf",
|
|
"raw", "rw2", "sr2", "srf", "x3f",
|
|
];
|
|
|
|
/// Progress of the one recursive ordinary-file thumbnail build the daemon may
|
|
/// run at a time.
|
|
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct ThumbnailBuildStatus {
|
|
pub running: bool,
|
|
/// True while folders are still being discovered, before the final image
|
|
/// count is known and the progress bar can become determinate.
|
|
pub scanning: bool,
|
|
/// Mountpoint-relative root selected when the build started.
|
|
pub path: String,
|
|
pub folders_scanned: u64,
|
|
pub images_found: u64,
|
|
/// Images already cached or processed during this build. Includes failures,
|
|
/// so `completed == images_found` still means the job has finished.
|
|
pub completed: u64,
|
|
/// Images that could not be downloaded or decoded.
|
|
pub failed: u64,
|
|
/// A traversal-level problem, such as a folder that could not be listed.
|
|
#[serde(default)]
|
|
pub message: Option<String>,
|
|
}
|
|
|
|
/// A daemon too old to report connectivity is assumed online — it could not
|
|
/// have mounted at all otherwise.
|
|
fn default_online() -> bool {
|
|
true
|
|
}
|
|
|
|
/// Phrase the queue depth of a [`Response::Status`] for a human, or `None` when
|
|
/// there is nothing queued and the caller should say nothing at all.
|
|
///
|
|
/// Lives here, next to the counts it describes, because the tray, the CLI and
|
|
/// the manager window all have to draw the same distinction between bytes that
|
|
/// have not reached the remote and metadata that has not.
|
|
pub fn pending_summary(uploads: u64, changes: u64) -> Option<String> {
|
|
let part = |n: u64, one: &str, many: &str| match n {
|
|
0 => None,
|
|
1 => Some(format!("1 {one}")),
|
|
n => Some(format!("{n} {many}")),
|
|
};
|
|
let parts: Vec<String> = [
|
|
part(uploads, "upload", "uploads"),
|
|
part(changes, "change", "changes"),
|
|
]
|
|
.into_iter()
|
|
.flatten()
|
|
.collect();
|
|
match parts.is_empty() {
|
|
true => None,
|
|
false => Some(format!("{} queued", parts.join(", "))),
|
|
}
|
|
}
|
|
|
|
/// One worker thread in a [`Diagnostics`] report.
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct WorkerState {
|
|
/// Thread name (`pdfs-fuse-meta-0`, `pdfs-fuse-3`).
|
|
pub name: String,
|
|
pub busy: bool,
|
|
/// The job in hand (`read`, `lookup`), empty when idle.
|
|
pub label: String,
|
|
/// How long it has been in that state. A busy worker with a large age is
|
|
/// the shape of a hang.
|
|
pub age_secs: u64,
|
|
}
|
|
|
|
/// One control request the daemon is still serving, in a [`Diagnostics`] report.
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct InflightRequest {
|
|
/// The request's variant name (`OpenFile`, `PhotosTimeline`).
|
|
pub kind: String,
|
|
pub age_secs: u64,
|
|
}
|
|
|
|
/// What the daemon is doing right now (reply to [`Request::Diagnostics`]).
|
|
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
|
pub struct Diagnostics {
|
|
/// How long this daemon process has been running.
|
|
pub uptime_secs: u64,
|
|
/// Resident set size, or `0` where it could not be read. The 2026-09-18 hang
|
|
/// peaked at 5.2 GiB, so this is the number that says whether the daemon is
|
|
/// growing again.
|
|
pub rss_bytes: u64,
|
|
pub workers: Vec<WorkerState>,
|
|
/// Jobs waiting for a worker, per lane.
|
|
pub meta_queued: usize,
|
|
pub transfer_queued: usize,
|
|
/// Jobs finished per lane since start. Queue depth that does not move while
|
|
/// these do not either is a stalled lane, not a loaded one.
|
|
pub meta_completed: u64,
|
|
pub transfer_completed: u64,
|
|
/// Control requests still being served, oldest first.
|
|
pub inflight: Vec<InflightRequest>,
|
|
/// Control handler threads in use, and the ceiling. At the ceiling the
|
|
/// daemon refuses new connections, which looks exactly like a dead daemon
|
|
/// from the CLI.
|
|
pub control_handlers: usize,
|
|
pub control_handler_limit: usize,
|
|
/// Queued mutations not yet drained.
|
|
pub pending_ops: u64,
|
|
}
|
|
|
|
/// The daemon's reply to a [`Request`].
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
pub enum Response {
|
|
/// Current mount status. Carries the cache stats the daemon already holds
|
|
/// (`used`/`budget` bytes and the pin list) so a front-end never has to open
|
|
/// the on-disk cache itself on its UI thread.
|
|
Status {
|
|
username: String,
|
|
mountpoint: String,
|
|
pinned: usize,
|
|
/// Bytes of cached content blobs (see [`crate::cache::ContentCache::usage`]).
|
|
used: u64,
|
|
/// Configured soft byte cap (`0` = unlimited).
|
|
budget: u64,
|
|
/// The pin registry.
|
|
pins: Vec<Pin>,
|
|
/// False when the daemon is serving the cached tree because the API is
|
|
/// unreachable. Cached and pinned content still
|
|
/// reads; anything else fails until the network is back.
|
|
#[serde(default = "default_online")]
|
|
online: bool,
|
|
/// Writes accepted locally but not yet uploaded.
|
|
/// Non-zero means the mount is ahead of the remote — either a copy is
|
|
/// still draining, or it cannot drain because we are offline.
|
|
#[serde(default)]
|
|
pending_uploads: u64,
|
|
/// Queued mutations that carry no bytes: `mkdir`, `rename`, `trash`.
|
|
/// Counted apart from `pending_uploads` because
|
|
/// calling a queued `mkdir` an upload is a lie.
|
|
#[serde(default)]
|
|
pending_changes: u64,
|
|
/// Queued writes held back because the file still wears a transient
|
|
/// name (a browser's `*.crdownload`, an editor's `*.swp`). They are not
|
|
/// waiting on the network and will not drain until the finalising
|
|
/// rename, so they are counted apart from `pending_uploads`.
|
|
#[serde(default)]
|
|
parked_uploads: u64,
|
|
/// Queued ops that have failed enough times to be considered stuck.
|
|
/// They keep retrying forever — correctly, since their staged bytes are
|
|
/// the only copy — which is why they have to be visible.
|
|
#[serde(default)]
|
|
failing_ops: u64,
|
|
/// The most recent error from a stuck op, when there is one.
|
|
#[serde(default)]
|
|
failing_error: Option<String>,
|
|
/// Bytes held in `staging/`: writes accepted from the kernel whose only
|
|
/// copy is on local disk. Never evictable, never counted against the
|
|
/// cache budget — hence reported separately.
|
|
#[serde(default)]
|
|
staged_bytes: u64,
|
|
/// Age of the oldest staged write, in seconds.
|
|
#[serde(default)]
|
|
staged_oldest_secs: u64,
|
|
/// True while the user has sync paused (see [`Request::SetSyncPaused`]).
|
|
#[serde(default)]
|
|
paused: bool,
|
|
/// The Unix second a timed pause ends; `None` when paused until
|
|
/// resumed, or not paused.
|
|
#[serde(default)]
|
|
paused_until: Option<i64>,
|
|
},
|
|
/// A human-readable success message.
|
|
Ok { message: String },
|
|
/// Health report for the metadata database and content cache (reply to
|
|
/// [`Request::CacheInspect`]).
|
|
CacheReport {
|
|
/// Schema version the database file is at.
|
|
schema_version: i64,
|
|
/// Bytes the database file accounts for.
|
|
db_bytes: u64,
|
|
/// Bytes a vacuum could hand back.
|
|
db_reclaimable_bytes: u64,
|
|
/// Row counts per table, in display order.
|
|
tables: Vec<(String, i64)>,
|
|
/// Bytes of cached content blobs, blocks, and thumbnails.
|
|
cache_used: u64,
|
|
/// Configured soft byte cap (`0` = unlimited).
|
|
cache_budget: u64,
|
|
/// Problems SQLite reported. Empty means sound; absent when the caller
|
|
/// did not ask for a deep check.
|
|
integrity_problems: Vec<String>,
|
|
/// Whether the integrity check actually ran, so an empty problem list
|
|
/// is not mistaken for a clean bill of health it never gave.
|
|
integrity_checked: bool,
|
|
/// Whether a deep check is running right now. It reads every page of
|
|
/// the database under the daemon's only connection, so it is answered
|
|
/// asynchronously: this says "ask again shortly", which is a different
|
|
/// thing to say than either of the two above.
|
|
#[serde(default)]
|
|
integrity_running: bool,
|
|
},
|
|
/// The pin registry.
|
|
Pins { pins: Vec<Pin> },
|
|
/// A directory listing (reply to [`Request::ListDir`]) or a trash listing
|
|
/// (reply to [`Request::ListTrash`]).
|
|
Entries { entries: Vec<DirEntry> },
|
|
/// A page of the photos timeline. `available` is false when the account
|
|
/// has no photos volume.
|
|
Photos {
|
|
available: bool,
|
|
items: Vec<PhotoItem>,
|
|
/// Whole-timeline tab counts `(photos, videos, raw)`, so a front-end can
|
|
/// label its Photos / Videos / Raw filter without paging the library.
|
|
/// The counts describe the *whole* timeline regardless of the page's own
|
|
/// `kind` filter. Older daemons omit it; a front-end then shows no counts.
|
|
#[serde(default)]
|
|
counts: Option<(usize, usize, usize)>,
|
|
},
|
|
/// The account's photo albums (reply to [`Request::PhotoAlbums`]).
|
|
/// `available` is false when the account has no photos volume, matching
|
|
/// [`Response::Photos`].
|
|
Albums {
|
|
available: bool,
|
|
items: Vec<AlbumInfo>,
|
|
},
|
|
/// What the daemon is doing right now (reply to [`Request::Diagnostics`]).
|
|
Diagnostics { diagnostics: Diagnostics },
|
|
/// How the Takeout import is doing (reply to [`Request::ImportStatus`]).
|
|
/// `running` is false once it has finished, when `summary` is the final
|
|
/// report; `summary` is `None` when no import has run this session.
|
|
ImportStatus {
|
|
running: bool,
|
|
#[serde(default)]
|
|
summary: Option<ImportSummary>,
|
|
},
|
|
/// How the photo re-date is doing (reply to [`Request::RedateStatus`]).
|
|
/// `running` is false once it has finished, when `summary` is the final
|
|
/// report; `summary` is `None` when no re-date has run this session.
|
|
RedateStatus {
|
|
running: bool,
|
|
#[serde(default)]
|
|
summary: Option<RedateSummary>,
|
|
},
|
|
/// The months the timeline spans (reply to [`Request::PhotoMonths`]),
|
|
/// newest first.
|
|
PhotoMonths { months: Vec<PhotoMonth> },
|
|
/// The duplicate sets (reply to [`Request::PhotoDuplicates`]), newest
|
|
/// first. Each set lists the copy worth keeping first.
|
|
PhotoDuplicates { sets: Vec<Vec<PhotoItem>> },
|
|
/// The sets of alike shots (reply to [`Request::PhotoSimilar`]), newest
|
|
/// first, each in capture order; and how far hashing has got, `None`
|
|
/// once no pass is running. `uncompared` counts the shots left out
|
|
/// because no thumbnail could be read for them.
|
|
PhotoSimilar {
|
|
sets: Vec<Vec<PhotoItem>>,
|
|
#[serde(default)]
|
|
hashing: Option<MappingProgress>,
|
|
#[serde(default)]
|
|
uncompared: usize,
|
|
},
|
|
/// The towns the photos were taken in (reply to [`Request::PhotoPlaces`]),
|
|
/// and how far a running timeline refresh has got reading photo locations
|
|
/// — `None` when none is reading any.
|
|
Places {
|
|
items: Vec<PlaceInfo>,
|
|
#[serde(default)]
|
|
mapping: Option<MappingProgress>,
|
|
},
|
|
/// The photos of one town, each with its location (reply to
|
|
/// [`Request::PlaceLocations`]).
|
|
PhotoLocations { items: Vec<PhotoLocation> },
|
|
/// Thumbnails for a [`Request::PhotoThumbs`] batch.
|
|
Thumbs { items: Vec<PhotoThumb> },
|
|
/// Per-node outcome of a [`Request::TrashNodes`] batch. Reported per uid
|
|
/// rather than as one verdict: the server answers each node separately, and
|
|
/// a front-end that removed all of them on a partial success would show a
|
|
/// photo as gone that is still there.
|
|
Trashed {
|
|
trashed: Vec<String>,
|
|
failed: Vec<TrashFailure>,
|
|
},
|
|
/// The album a [`Request::CreateAlbum`] made.
|
|
AlbumCreated { uid: String },
|
|
/// Outcome of a [`Request::AddToAlbum`] or [`Request::RemoveFromAlbum`],
|
|
/// per photo like [`Response::Trashed`]: `changed` photos were added or
|
|
/// removed, `failed` ones were not.
|
|
AlbumChanged {
|
|
changed: Vec<String>,
|
|
failed: Vec<TrashFailure>,
|
|
},
|
|
/// A [`Request::FileThumbs`] generation is no longer current. This is
|
|
/// retryable after reserving a fresh generation and must never be interpreted
|
|
/// as a permanent "no thumbnail" verdict.
|
|
FileThumbsStale,
|
|
/// Daemon-issued generation reserved by
|
|
/// [`Request::ReserveFileThumbGeneration`].
|
|
FileThumbGeneration { generation: u64 },
|
|
/// Current recursive thumbnail-build progress.
|
|
ThumbnailBuild { status: ThumbnailBuildStatus },
|
|
/// Whether a photos-timeline refresh is still running (reply to
|
|
/// [`Request::PhotosRefreshStatus`]).
|
|
PhotosRefresh { running: bool },
|
|
/// An on-disk path the front-end can open (e.g. a downloaded photo).
|
|
FilePath { path: String },
|
|
/// Full-text search results (reply to [`Request::Search`]).
|
|
SearchResults { hits: Vec<SearchHit> },
|
|
/// Local-file search results (reply to [`Request::SearchLocal`]). `indexing`
|
|
/// is true while a scan of the machine is still running, so a front-end can
|
|
/// say "still indexing" instead of "no matches" on a cold first launch.
|
|
LocalResults { hits: Vec<LocalHit>, indexing: bool },
|
|
/// Drive and local-file search results returned together (reply to
|
|
/// [`Request::SearchV2`]). `local_indexing` distinguishes a genuinely empty
|
|
/// local result set from one observed while the initial scan is running.
|
|
SearchResultsV2 {
|
|
drive_hits: Vec<SearchHit>,
|
|
local_hits: Vec<LocalHit>,
|
|
local_indexing: bool,
|
|
},
|
|
/// A snapshot of what the daemon is working on (reply to
|
|
/// [`Request::GetQueueStatus`]): `items` are byte-moving transfers, `jobs`
|
|
/// the longer non-transfer work around them (scans, folder skeletons, the
|
|
/// local index, sync passes). Both empty means the daemon is idle.
|
|
Transfers {
|
|
items: Vec<TransferItem>,
|
|
#[serde(default)]
|
|
jobs: Vec<JobItem>,
|
|
},
|
|
/// The account's devices (reply to [`Request::ListDevices`]).
|
|
Devices { items: Vec<DeviceInfo> },
|
|
/// Every local Proton Drive location (reply to [`Request::ListLocations`]).
|
|
Locations { items: Vec<MountSpec> },
|
|
/// This device's synced folders (reply to [`Request::ListSyncFolders`]).
|
|
SyncFolders { items: Vec<SyncFolderInfo> },
|
|
/// The upload/change queue (reply to [`Request::ListPendingOps`]).
|
|
PendingOps { items: Vec<PendingOpInfo> },
|
|
/// Conflict copies (reply to [`Request::ListConflicts`]).
|
|
Conflicts { items: Vec<ConflictInfo> },
|
|
/// Folders offered for restore (reply to [`Request::ListRestorableFolders`]).
|
|
RestorableFolders { items: Vec<RestorableFolder> },
|
|
/// A node's share: members + pending invitations, and its public link if any
|
|
/// (reply to [`Request::ListShare`]).
|
|
Share {
|
|
entries: Vec<ShareEntry>,
|
|
link: Option<PublicLinkInfo>,
|
|
},
|
|
/// A freshly created public link, carrying the shareable URL (reply to
|
|
/// [`Request::CreatePublicLink`]).
|
|
PublicLink { link: PublicLinkInfo },
|
|
/// Invitations addressed to me (reply to [`Request::ListInvitations`]).
|
|
Invitations { items: Vec<InvitationInfo> },
|
|
/// Saved public links (reply to [`Request::ListBookmarks`]).
|
|
Bookmarks { items: Vec<BookmarkInfo> },
|
|
/// Account storage usage in bytes (reply to [`Request::AccountQuota`]).
|
|
/// `max_space` is the total; `used_space` what is consumed. `max_space == 0`
|
|
/// means the total is unknown, which a front-end shows as a plain used figure
|
|
/// rather than a full bar.
|
|
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> },
|
|
/// The request failed. `message` is for the user; `kind` is for the code —
|
|
/// a front-end decides its copy and whether to offer a retry from `kind`,
|
|
/// never by matching on the text.
|
|
Error {
|
|
message: String,
|
|
#[serde(default)]
|
|
kind: ErrorKind,
|
|
},
|
|
}
|
|
|
|
impl Response {
|
|
/// Build a failure reply from a classified error, so the `kind` a
|
|
/// request-serving method decided survives the trip to the front-end.
|
|
pub fn error(e: crate::error::CoreError) -> Self {
|
|
Response::Error {
|
|
message: e.message,
|
|
kind: e.kind,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What class of thing went wrong, as opposed to what it read like.
|
|
///
|
|
/// The daemon answers most calls with prose assembled from whatever layer
|
|
/// failed (`"resolve path: ENOENT"`), which is fine to show and useless to
|
|
/// branch on. This is the branchable half: enough to pick the right copy, to
|
|
/// know whether retrying is meaningful, and to tell a caller's mistake apart
|
|
/// from an outage.
|
|
///
|
|
/// Deliberately coarse. A variant earns its place by changing what a front-end
|
|
/// *does*, not by naming a distinct cause — anything finer belongs in `message`.
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ErrorKind {
|
|
/// The API is unreachable and the request needed it. The cached tree is
|
|
/// still being served, so this is a "not right now", not a failure of the
|
|
/// thing the user asked for.
|
|
Offline,
|
|
/// No node at that path or uid. Usually means the front-end's listing is
|
|
/// stale, so the useful response is to reload it rather than to retry.
|
|
NotFound,
|
|
/// The account may not do that to that node — a viewer trying to write, a
|
|
/// share whose role was downgraded. Retrying changes nothing.
|
|
Denied,
|
|
/// The remote moved underneath the request: a name already taken, a
|
|
/// revision superseded. The caller has to decide, so never auto-retried.
|
|
Conflict,
|
|
/// The request itself was malformed — an empty name, a path with a `/` in
|
|
/// it, an unparseable uid. A bug in the caller, not a condition to retry.
|
|
Invalid,
|
|
/// The API was reached and refused, or the transfer broke. The one class
|
|
/// where an unchanged retry can legitimately succeed.
|
|
Remote,
|
|
/// 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]
|
|
Internal,
|
|
}
|
|
|
|
impl ErrorKind {
|
|
/// Whether repeating the identical request could plausibly succeed.
|
|
///
|
|
/// Drives whether a front-end offers "Try again" at all: offering it for a
|
|
/// [`NotFound`](Self::NotFound) or an [`Invalid`](Self::Invalid) teaches the
|
|
/// user that the button does nothing.
|
|
pub fn retryable(self) -> bool {
|
|
matches!(self, ErrorKind::Offline | ErrorKind::Remote)
|
|
}
|
|
}
|
|
|
|
impl Request {
|
|
/// Whether this is one of the cheap state reads that front ends repeat on a
|
|
/// timer. The daemon answers these without touching the network, so they
|
|
/// get the short [`POLL_READ_TIMEOUT`].
|
|
pub fn is_poll(&self) -> bool {
|
|
matches!(
|
|
self,
|
|
Request::Status
|
|
| Request::GetQueueStatus
|
|
| Request::ListLocations
|
|
| Request::ListPendingOps
|
|
| Request::ListConflicts
|
|
| Request::ListActivity { .. }
|
|
| Request::ThumbnailBuildStatus
|
|
| Request::PhotosRefreshStatus
|
|
| Request::RedateStatus
|
|
| Request::ImportStatus
|
|
)
|
|
}
|
|
|
|
/// How long to wait for the daemon's reply to this request.
|
|
pub fn read_timeout(&self) -> Duration {
|
|
if self.is_poll() {
|
|
POLL_READ_TIMEOUT
|
|
} else {
|
|
READ_TIMEOUT
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Send one [`Request`] to the daemon listening on `socket` and read its
|
|
/// [`Response`]. Returns a crate [`crate::Error`] if no daemon is listening, and
|
|
/// one for which [`crate::Error::is_timeout`] holds if the daemon took longer
|
|
/// than [`Request::read_timeout`] to answer.
|
|
///
|
|
/// Shared by the CLI and GUI so both speak the wire format identically.
|
|
pub fn send(socket: &Path, req: &Request) -> Result<Response> {
|
|
let mut stream = UnixStream::connect(socket)?;
|
|
stream.set_read_timeout(Some(req.read_timeout()))?;
|
|
stream.set_write_timeout(Some(WRITE_TIMEOUT))?;
|
|
let mut line = serde_json::to_vec(req)?;
|
|
line.push(b'\n');
|
|
stream.write_all(&line)?;
|
|
stream.flush()?;
|
|
let mut reader = BufReader::new(stream);
|
|
let mut resp = String::new();
|
|
reader.read_line(&mut resp)?;
|
|
Ok(serde_json::from_str(resp.trim())?)
|
|
}
|
|
|
|
/// An open [`Request::Subscribe`] connection. See [`subscribe`].
|
|
pub struct Subscription {
|
|
reader: BufReader<UnixStream>,
|
|
line: String,
|
|
}
|
|
|
|
/// Start following the daemon listening on `socket`. Fails if no daemon is
|
|
/// listening. A daemon too old to know [`Request::Subscribe`] answers it with an
|
|
/// error, which the first [`Subscription::next_event`] returns as
|
|
/// [`crate::Error::Other`].
|
|
pub fn subscribe(socket: &Path) -> Result<Subscription> {
|
|
let mut stream = UnixStream::connect(socket)?;
|
|
stream.set_read_timeout(Some(SUBSCRIBE_READ_TIMEOUT))?;
|
|
stream.set_write_timeout(Some(WRITE_TIMEOUT))?;
|
|
let mut line = serde_json::to_vec(&Request::Subscribe)?;
|
|
line.push(b'\n');
|
|
stream.write_all(&line)?;
|
|
stream.flush()?;
|
|
Ok(Subscription {
|
|
reader: BufReader::new(stream),
|
|
line: String::new(),
|
|
})
|
|
}
|
|
|
|
impl Subscription {
|
|
/// Wait for the next event. `Ok(None)` means the daemon closed the
|
|
/// connection; a timeout means it went [`SUBSCRIBE_READ_TIMEOUT`] without
|
|
/// even a heartbeat. A line from a newer daemon that this client cannot
|
|
/// read is skipped rather than ending the subscription.
|
|
pub fn next_event(&mut self) -> Result<Option<Event>> {
|
|
loop {
|
|
self.line.clear();
|
|
if self.reader.read_line(&mut self.line)? == 0 {
|
|
return Ok(None);
|
|
}
|
|
let line = self.line.trim();
|
|
if let Ok(event) = serde_json::from_str::<Event>(line) {
|
|
return Ok(Some(event));
|
|
}
|
|
if let Ok(Response::Error { message, .. }) = serde_json::from_str::<Response>(line) {
|
|
return Err(crate::Error::Other(message));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What [`follow`] reports.
|
|
#[derive(Debug)]
|
|
pub enum Feed {
|
|
/// A subscription started. The events after it describe the daemon's
|
|
/// current state, so a front-end catches up from them.
|
|
Connected,
|
|
Event(Event),
|
|
/// The subscription ended: the daemon stopped, or stopped answering.
|
|
Disconnected,
|
|
}
|
|
|
|
/// How often [`follow`] polls a daemon that cannot push events.
|
|
const LEGACY_POLL: Duration = Duration::from_secs(3);
|
|
|
|
/// The longest [`follow`] waits between attempts to reach a daemon.
|
|
const RECONNECT_MAX: Duration = Duration::from_secs(5);
|
|
|
|
/// Follow the daemon on `socket` for as long as `on` returns true, across
|
|
/// daemon restarts. Blocks, so run it on a thread of its own.
|
|
///
|
|
/// A daemon that predates [`Request::Subscribe`] (still running after an
|
|
/// upgrade, until its service restarts) is polled for its status and transfers
|
|
/// instead, so a front-end sees the same events either way, only later.
|
|
pub fn follow(socket: &Path, mut on: impl FnMut(Feed) -> bool) {
|
|
let mut connected = false;
|
|
let mut backoff = Duration::from_secs(1);
|
|
loop {
|
|
let ended = match subscribe(socket) {
|
|
Ok(mut subscription) => {
|
|
let mut first = true;
|
|
loop {
|
|
match subscription.next_event() {
|
|
Ok(Some(event)) => {
|
|
if first {
|
|
first = false;
|
|
connected = true;
|
|
backoff = Duration::from_secs(1);
|
|
if !on(Feed::Connected) {
|
|
return;
|
|
}
|
|
}
|
|
if !on(Feed::Event(event)) {
|
|
return;
|
|
}
|
|
}
|
|
Ok(None) => break Ok(()),
|
|
Err(e) => break Err(e),
|
|
}
|
|
}
|
|
}
|
|
Err(e) => Err(e),
|
|
};
|
|
if let Err(crate::Error::Other(_)) = ended {
|
|
if !connected {
|
|
connected = true;
|
|
if !on(Feed::Connected) {
|
|
return;
|
|
}
|
|
}
|
|
if !poll_legacy(socket, &mut on) {
|
|
return;
|
|
}
|
|
std::thread::sleep(LEGACY_POLL);
|
|
continue;
|
|
}
|
|
if connected {
|
|
connected = false;
|
|
if !on(Feed::Disconnected) {
|
|
return;
|
|
}
|
|
}
|
|
std::thread::sleep(backoff);
|
|
backoff = (backoff * 2).min(RECONNECT_MAX);
|
|
}
|
|
}
|
|
|
|
/// One round of [`follow`]'s fallback: the status and transfers a subscription
|
|
/// would have pushed. Returns what `on` returned.
|
|
fn poll_legacy(socket: &Path, on: &mut impl FnMut(Feed) -> bool) -> bool {
|
|
match send(socket, &Request::Status) {
|
|
Ok(status @ Response::Status { .. }) => {
|
|
if !on(Feed::Event(Event::Status(Box::new(status)))) {
|
|
return false;
|
|
}
|
|
}
|
|
_ => return true,
|
|
}
|
|
if let Ok(Response::Transfers { items, jobs }) = send(socket, &Request::GetQueueStatus) {
|
|
return on(Feed::Event(Event::Transfers { items, jobs }));
|
|
}
|
|
true
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn polls_wait_briefly_and_real_work_waits_long() {
|
|
assert_eq!(Request::Status.read_timeout(), POLL_READ_TIMEOUT);
|
|
assert_eq!(
|
|
Request::ListActivity { limit: 200 }.read_timeout(),
|
|
POLL_READ_TIMEOUT
|
|
);
|
|
assert_eq!(
|
|
Request::OpenFile {
|
|
path: "a.txt".into(),
|
|
uid: None,
|
|
}
|
|
.read_timeout(),
|
|
READ_TIMEOUT
|
|
);
|
|
}
|
|
|
|
/// A daemon on a fresh socket that reads the subscription request and
|
|
/// answers with `lines`, then hangs up.
|
|
fn fake_daemon(name: &str, lines: &'static str) -> std::path::PathBuf {
|
|
let socket = std::env::temp_dir().join(format!("pdfs-{name}-{}.sock", std::process::id()));
|
|
let _ = std::fs::remove_file(&socket);
|
|
let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
|
|
std::thread::spawn(move || {
|
|
let (stream, _) = listener.accept().unwrap();
|
|
let mut reader = BufReader::new(stream);
|
|
let mut request = String::new();
|
|
reader.read_line(&mut request).unwrap();
|
|
assert!(matches!(
|
|
serde_json::from_str::<Request>(request.trim()),
|
|
Ok(Request::Subscribe)
|
|
));
|
|
reader.get_mut().write_all(lines.as_bytes()).unwrap();
|
|
});
|
|
socket
|
|
}
|
|
|
|
#[test]
|
|
fn a_subscription_skips_what_it_cannot_read() {
|
|
let socket = fake_daemon(
|
|
"subscribe",
|
|
"not json\n\
|
|
{\"Changed\":{\"topics\":[\"files\",\"from_a_newer_daemon\"]}}\n\
|
|
{\"NewEvent\":{}}\n\
|
|
\"Heartbeat\"\n",
|
|
);
|
|
let mut subscription = subscribe(&socket).unwrap();
|
|
let first = subscription.next_event().unwrap();
|
|
let second = subscription.next_event().unwrap();
|
|
let end = subscription.next_event().unwrap();
|
|
let _ = std::fs::remove_file(&socket);
|
|
match first {
|
|
Some(Event::Changed { topics }) => {
|
|
assert_eq!(topics, vec![Topic::Files, Topic::Unknown]);
|
|
}
|
|
other => panic!("expected a change, got {other:?}"),
|
|
}
|
|
assert!(matches!(second, Some(Event::Heartbeat)), "{second:?}");
|
|
assert!(end.is_none(), "{end:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_daemon_without_subscriptions_is_told_apart_from_a_dead_one() {
|
|
let socket = fake_daemon("legacy", "{\"Error\":{\"message\":\"unknown request\"}}\n");
|
|
let mut subscription = subscribe(&socket).unwrap();
|
|
let answer = subscription.next_event();
|
|
let _ = std::fs::remove_file(&socket);
|
|
assert!(matches!(answer, Err(crate::Error::Other(_))), "{answer:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_daemon_that_never_answers_times_out_as_not_responding() {
|
|
let socket = std::env::temp_dir().join(format!("pdfs-silent-{}.sock", std::process::id()));
|
|
let _ = std::fs::remove_file(&socket);
|
|
// Bound but never accepted: connect succeeds, no reply ever comes.
|
|
let _listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
|
|
let started = std::time::Instant::now();
|
|
let err = send(&socket, &Request::Status).unwrap_err();
|
|
let _ = std::fs::remove_file(&socket);
|
|
assert!(err.is_timeout(), "{err}");
|
|
assert!(started.elapsed() < READ_TIMEOUT);
|
|
}
|
|
|
|
#[test]
|
|
fn photo_kind_classifies_by_extension_then_mime() {
|
|
use PhotoKind::*;
|
|
// Raw is extension-driven: the server media type is often generic.
|
|
assert_eq!(PhotoKind::classify(Some("IMG_1.CR2"), None), Raw);
|
|
assert_eq!(
|
|
PhotoKind::classify(Some("shot.dng"), Some("application/octet-stream")),
|
|
Raw
|
|
);
|
|
// Video by extension or by mime when the name has no useful extension.
|
|
assert_eq!(PhotoKind::classify(Some("anime.mkv"), None), Video);
|
|
assert_eq!(PhotoKind::classify(Some("clip.MP4"), None), Video);
|
|
assert_eq!(PhotoKind::classify(None, Some("video/quicktime")), Video);
|
|
// Everything else is a still photo, including a mismatched raw mime whose
|
|
// name says it is a normal JPEG.
|
|
assert_eq!(PhotoKind::classify(Some("pic.jpg"), None), Photo);
|
|
assert_eq!(PhotoKind::classify(Some("pic.heic"), None), Photo);
|
|
assert_eq!(PhotoKind::classify(None, None), Photo);
|
|
// Extension wins over mime: a name ending .jpg is a photo even if the mime
|
|
// is nonsense.
|
|
assert_eq!(PhotoKind::classify(Some("x.jpg"), Some("video/mp4")), Photo);
|
|
}
|
|
|
|
/// Keep every extension accepted by the shared classifier covered. Drive
|
|
/// activation uses this classification before it knows whether opening a
|
|
/// file should stream through FUSE or materialise a complete cache entry;
|
|
/// silently dropping one of these formats would therefore regress large
|
|
/// videos back to the expensive whole-file path.
|
|
#[test]
|
|
fn photo_kind_recognises_every_supported_video_extension() {
|
|
for ext in VIDEO_EXTS {
|
|
let lower = format!("movie.{ext}");
|
|
let upper = format!("MOVIE.{}", ext.to_ascii_uppercase());
|
|
assert_eq!(
|
|
PhotoKind::classify(Some(&lower), None),
|
|
PhotoKind::Video,
|
|
"lower-case .{ext}"
|
|
);
|
|
assert_eq!(
|
|
PhotoKind::classify(Some(&upper), None),
|
|
PhotoKind::Video,
|
|
"upper-case .{ext}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn photo_kind_uses_video_mime_only_when_extension_is_not_authoritative() {
|
|
use PhotoKind::*;
|
|
|
|
assert_eq!(
|
|
PhotoKind::classify(Some("extensionless"), Some("video/mp4; codecs=avc1")),
|
|
Video
|
|
);
|
|
assert_eq!(
|
|
PhotoKind::classify(Some("clip.unknown"), Some("video/x-matroska")),
|
|
Video
|
|
);
|
|
// Audio is not a video and must not accidentally take the video
|
|
// streaming policy merely because both are time-based media.
|
|
assert_eq!(
|
|
PhotoKind::classify(Some("recording"), Some("audio/flac")),
|
|
Photo
|
|
);
|
|
// A known still/raw suffix remains authoritative over server metadata.
|
|
assert_eq!(
|
|
PhotoKind::classify(Some("poster.AVIF"), Some("video/mp4")),
|
|
Photo
|
|
);
|
|
assert_eq!(
|
|
PhotoKind::classify(Some("negative.NEF"), Some("video/mp4")),
|
|
Raw
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn photo_kind_i64_round_trips() {
|
|
for k in [PhotoKind::Photo, PhotoKind::Video, PhotoKind::Raw] {
|
|
assert_eq!(PhotoKind::from_i64(k.as_i64()), k);
|
|
}
|
|
// An unknown persisted value degrades to a still photo.
|
|
assert_eq!(PhotoKind::from_i64(99), PhotoKind::Photo);
|
|
}
|
|
|
|
/// The whole point of the split counts: a queued `mkdir` is work, but it is
|
|
/// not an upload and must never be reported as one.
|
|
#[test]
|
|
fn pending_summary_separates_uploads_from_other_changes() {
|
|
assert_eq!(pending_summary(0, 0), None);
|
|
assert_eq!(pending_summary(1, 0).as_deref(), Some("1 upload queued"));
|
|
assert_eq!(pending_summary(3, 0).as_deref(), Some("3 uploads queued"));
|
|
assert_eq!(pending_summary(0, 1).as_deref(), Some("1 change queued"));
|
|
assert_eq!(
|
|
pending_summary(2, 4).as_deref(),
|
|
Some("2 uploads, 4 changes queued")
|
|
);
|
|
}
|
|
|
|
/// The mutation requests must survive a line-delimited JSON round-trip, since
|
|
/// that is exactly how they cross the control socket.
|
|
#[test]
|
|
fn mutation_requests_roundtrip() {
|
|
let reqs = [
|
|
Request::Rename {
|
|
path: "a/b.txt".into(),
|
|
new_name: "c.txt".into(),
|
|
},
|
|
Request::Move {
|
|
path: "a/b.txt".into(),
|
|
new_parent: "d".into(),
|
|
},
|
|
Request::Delete {
|
|
path: "a/b.txt".into(),
|
|
},
|
|
Request::CreateFolder {
|
|
parent: "a".into(),
|
|
name: "new".into(),
|
|
},
|
|
Request::UploadPhoto {
|
|
name: "p.jpg".into(),
|
|
media_type: "image/jpeg".into(),
|
|
source_path: "/home/u/p.jpg".into(),
|
|
capture_time: Some(1_700_000_000),
|
|
},
|
|
Request::UploadPaths {
|
|
parent: "a".into(),
|
|
sources: vec!["/home/u/x.txt".into(), "/home/u/pics".into()],
|
|
},
|
|
];
|
|
for req in reqs {
|
|
let line = serde_json::to_string(&req).unwrap();
|
|
assert!(!line.contains('\n'), "wire form must be a single line");
|
|
let back: Request = serde_json::from_str(&line).unwrap();
|
|
// Round-trip is lossless: re-serializing yields the same bytes.
|
|
assert_eq!(line, serde_json::to_string(&back).unwrap());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn file_thumbnail_requests_keep_their_revision_tags() {
|
|
let request = Request::FileThumbs {
|
|
items: vec![
|
|
FileThumbRequest {
|
|
uid: "volume~first".into(),
|
|
modified: 1_700_000_001,
|
|
name: "first.jpg".into(),
|
|
},
|
|
FileThumbRequest {
|
|
uid: "volume~second".into(),
|
|
modified: 1_700_000_002,
|
|
name: "second.nef".into(),
|
|
},
|
|
],
|
|
generation: 42,
|
|
};
|
|
let line = serde_json::to_string(&request).unwrap();
|
|
let decoded: Request = serde_json::from_str(&line).unwrap();
|
|
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_file_thumbnail_requests_default_the_name() {
|
|
let decoded: Request = serde_json::from_str(
|
|
r#"{"FileThumbs":{"items":[{"uid":"volume~first","modified":1700000001}],"generation":42}}"#,
|
|
)
|
|
.unwrap();
|
|
let Request::FileThumbs { items, generation } = decoded else {
|
|
panic!("decoded the wrong request variant");
|
|
};
|
|
assert_eq!(generation, 42);
|
|
assert_eq!(items.len(), 1);
|
|
assert!(items[0].name.is_empty());
|
|
}
|
|
|
|
/// The lightbox asks for one shot's files by the uid of the tile that was
|
|
/// clicked, and gets them back in the shape the gallery already paints.
|
|
#[test]
|
|
fn photo_group_requests_roundtrip() {
|
|
let request = Request::PhotoGroup {
|
|
uid: "volume~first".into(),
|
|
};
|
|
let line = serde_json::to_string(&request).unwrap();
|
|
assert_eq!(line, r#"{"PhotoGroup":{"uid":"volume~first"}}"#);
|
|
let decoded: Request = serde_json::from_str(&line).unwrap();
|
|
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
|
|
}
|
|
|
|
/// The group fields are new, so a page from a daemon that predates them has
|
|
/// to keep painting: one file, nothing to badge.
|
|
#[test]
|
|
fn an_older_daemons_photo_has_no_group() {
|
|
let item: PhotoItem = serde_json::from_str(
|
|
r#"{"uid":"volume~first","capture_time":1700000000,"thumb_path":null,
|
|
"name":"a.jpg","ratio":1.5,"no_thumb":false}"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(item.group_size, 0, "absent reads the same as one file");
|
|
assert!(!item.has_raw);
|
|
}
|
|
|
|
/// The gallery trashes by uid, and reports per-uid outcomes back: a partial
|
|
/// failure has to survive the wire so the GUI can put those tiles back.
|
|
#[test]
|
|
fn trash_requests_and_outcomes_roundtrip() {
|
|
let request = Request::TrashNodes {
|
|
uids: vec!["volume~first".into(), "volume~second".into()],
|
|
files_only: false,
|
|
};
|
|
// An older front-end's request trashes whole groups.
|
|
let old: Request = serde_json::from_str(r#"{"TrashNodes":{"uids":["v~a"]}}"#).unwrap();
|
|
assert!(matches!(
|
|
old,
|
|
Request::TrashNodes {
|
|
files_only: false,
|
|
..
|
|
}
|
|
));
|
|
let line = serde_json::to_string(&request).unwrap();
|
|
assert!(!line.contains('\n'), "wire form must be a single line");
|
|
let decoded: Request = serde_json::from_str(&line).unwrap();
|
|
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
|
|
|
|
let response = Response::Trashed {
|
|
trashed: vec!["volume~first".into()],
|
|
failed: vec![TrashFailure {
|
|
uid: "volume~second".into(),
|
|
message: "permission denied".into(),
|
|
}],
|
|
};
|
|
let line = serde_json::to_string(&response).unwrap();
|
|
let decoded: Response = serde_json::from_str(&line).unwrap();
|
|
let Response::Trashed { trashed, failed } = decoded else {
|
|
panic!("decoded the wrong response variant");
|
|
};
|
|
assert_eq!(trashed, ["volume~first"]);
|
|
assert_eq!(failed.len(), 1);
|
|
assert_eq!(failed[0].uid, "volume~second");
|
|
assert_eq!(failed[0].message, "permission denied");
|
|
}
|
|
|
|
#[test]
|
|
fn thumbnail_build_requests_and_progress_roundtrip() {
|
|
let requests = [
|
|
Request::ReserveFileThumbGeneration,
|
|
Request::CancelFileThumbs { generation: 43 },
|
|
Request::StartThumbnailBuild {
|
|
path: "pictures/events".into(),
|
|
},
|
|
Request::CancelThumbnailBuild,
|
|
Request::ThumbnailBuildStatus,
|
|
];
|
|
for request in requests {
|
|
let line = serde_json::to_string(&request).unwrap();
|
|
let decoded: Request = serde_json::from_str(&line).unwrap();
|
|
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
|
|
}
|
|
|
|
let response = Response::ThumbnailBuild {
|
|
status: ThumbnailBuildStatus {
|
|
running: true,
|
|
scanning: false,
|
|
path: "pictures/events".into(),
|
|
folders_scanned: 12,
|
|
images_found: 80,
|
|
completed: 25,
|
|
failed: 1,
|
|
message: None,
|
|
},
|
|
};
|
|
let line = serde_json::to_string(&response).unwrap();
|
|
let decoded: Response = serde_json::from_str(&line).unwrap();
|
|
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
|
|
|
|
let response = Response::FileThumbGeneration { generation: 44 };
|
|
let line = serde_json::to_string(&response).unwrap();
|
|
let decoded: Response = serde_json::from_str(&line).unwrap();
|
|
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
|
|
|
|
let response = Response::FileThumbsStale;
|
|
let line = serde_json::to_string(&response).unwrap();
|
|
let decoded: Response = serde_json::from_str(&line).unwrap();
|
|
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
|
|
}
|
|
|
|
/// `full` defaults, so a front-end built against the older wire shape still
|
|
/// asks for the ordinary refresh rather than failing to parse.
|
|
#[test]
|
|
fn a_photos_refresh_scope_defaults_to_not_full() {
|
|
let scope: RefreshScope = serde_json::from_str(r#"{"Photos":{}}"#).unwrap();
|
|
assert_eq!(scope, RefreshScope::Photos { full: false });
|
|
let line = serde_json::to_string(&RefreshScope::Photos { full: true }).unwrap();
|
|
assert_eq!(line, r#"{"Photos":{"full":true}}"#);
|
|
}
|
|
|
|
/// A photos refresh is followed, not awaited, so the request that reports
|
|
/// its progress is part of the wire contract.
|
|
#[test]
|
|
fn photos_refresh_status_roundtrips() {
|
|
let line = serde_json::to_string(&Request::PhotosRefreshStatus).unwrap();
|
|
assert_eq!(line, r#""PhotosRefreshStatus""#);
|
|
let decoded: Request = serde_json::from_str(&line).unwrap();
|
|
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
|
|
|
|
let line = serde_json::to_string(&Response::PhotosRefresh { running: true }).unwrap();
|
|
assert_eq!(line, r#"{"PhotosRefresh":{"running":true}}"#);
|
|
let decoded: Response = serde_json::from_str(&line).unwrap();
|
|
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn unified_search_request_and_response_roundtrip() {
|
|
let request = Request::SearchV2 {
|
|
query: "quarterly report".into(),
|
|
limit: 37,
|
|
filters: SearchFilters::default(),
|
|
};
|
|
let line = serde_json::to_string(&request).unwrap();
|
|
assert_eq!(
|
|
line,
|
|
r#"{"SearchV2":{"query":"quarterly report","limit":37,"filters":{"sources":["Drive","Local"],"kind":"All"}}}"#
|
|
);
|
|
let decoded: Request = serde_json::from_str(&line).unwrap();
|
|
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
|
|
|
|
let response = Response::SearchResultsV2 {
|
|
drive_hits: vec![SearchHit {
|
|
name: "report.pdf".into(),
|
|
path: "Work/report.pdf".into(),
|
|
is_dir: false,
|
|
size: 12,
|
|
modified: 123,
|
|
pinned: true,
|
|
cached: false,
|
|
uid: "vol~link".into(),
|
|
mounted_path: Some("/home/me/Videos/report.pdf".into()),
|
|
score: 985,
|
|
}],
|
|
local_hits: vec![LocalHit {
|
|
name: "notes.txt".into(),
|
|
path: "/home/me/notes.txt".into(),
|
|
is_dir: false,
|
|
size: 7,
|
|
modified: 456,
|
|
score: 720,
|
|
}],
|
|
local_indexing: true,
|
|
};
|
|
let line = serde_json::to_string(&response).unwrap();
|
|
assert!(!line.contains('\n'));
|
|
let decoded: Response = serde_json::from_str(&line).unwrap();
|
|
match decoded {
|
|
Response::SearchResultsV2 {
|
|
drive_hits,
|
|
local_hits,
|
|
local_indexing,
|
|
} => {
|
|
assert_eq!(drive_hits.len(), 1);
|
|
assert_eq!(drive_hits[0].uid, "vol~link");
|
|
assert_eq!(drive_hits[0].score, 985);
|
|
assert_eq!(local_hits.len(), 1);
|
|
assert_eq!(local_hits[0].path, "/home/me/notes.txt");
|
|
assert_eq!(local_hits[0].score, 720);
|
|
assert!(local_indexing);
|
|
}
|
|
other => panic!("expected unified search results, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn search_v2_legacy_wire_form_gets_compatible_defaults() {
|
|
let decoded: Request =
|
|
serde_json::from_str(r#"{"SearchV2":{"query":"old client","limit":20}}"#).unwrap();
|
|
match decoded {
|
|
Request::SearchV2 {
|
|
query,
|
|
limit,
|
|
filters,
|
|
} => {
|
|
assert_eq!(query, "old client");
|
|
assert_eq!(limit, 20);
|
|
assert_eq!(filters, SearchFilters::default());
|
|
}
|
|
other => panic!("expected SearchV2, got {other:?}"),
|
|
}
|
|
|
|
// New daemons add scores, but old-daemon replies remain readable and
|
|
// signal that their existing order should be retained with score 0.
|
|
let decoded: Response = serde_json::from_str(
|
|
r#"{"SearchResultsV2":{"drive_hits":[{"name":"a","path":"a","is_dir":false,"size":1,"modified":2,"pinned":false,"uid":"v~a"}],"local_hits":[{"name":"b","path":"/b","is_dir":false,"size":3,"modified":4}],"local_indexing":false}}"#,
|
|
)
|
|
.unwrap();
|
|
match decoded {
|
|
Response::SearchResultsV2 {
|
|
drive_hits,
|
|
local_hits,
|
|
..
|
|
} => {
|
|
assert_eq!(drive_hits[0].score, 0);
|
|
assert_eq!(local_hits[0].score, 0);
|
|
}
|
|
other => panic!("expected SearchResultsV2, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn search_v2_filters_roundtrip() {
|
|
let request = Request::SearchV2 {
|
|
query: "holiday".into(),
|
|
limit: 50,
|
|
filters: SearchFilters {
|
|
sources: vec![SearchSource::Drive],
|
|
kind: SearchKind::Images,
|
|
},
|
|
};
|
|
let line = serde_json::to_string(&request).unwrap();
|
|
let decoded: Request = serde_json::from_str(&line).unwrap();
|
|
assert!(matches!(
|
|
decoded,
|
|
Request::SearchV2 {
|
|
filters: SearchFilters { sources, kind: SearchKind::Images },
|
|
..
|
|
} if sources == vec![SearchSource::Drive]
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn search_kind_classifies_before_results_are_limited() {
|
|
assert!(SearchKind::Folders.accepts("movie.mp4", true));
|
|
assert!(!SearchKind::Folders.accepts("movie.mp4", false));
|
|
assert!(SearchKind::Documents.accepts("REPORT.PDF", false));
|
|
assert!(SearchKind::Images.accepts("scan.HEIC", false));
|
|
assert!(SearchKind::Media.accepts("film.mkv", false));
|
|
assert!(SearchKind::Media.accepts("recording.flac", false));
|
|
assert!(!SearchKind::Media.accepts("poster.jpg", false));
|
|
assert!(SearchKind::All.accepts("anything", true));
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_search_wire_forms_still_decode() {
|
|
let drive: Request =
|
|
serde_json::from_str(r#"{"Search":{"query":"old","limit":20}}"#).unwrap();
|
|
assert!(matches!(
|
|
drive,
|
|
Request::Search { query, limit, scope: None } if query == "old" && limit == 20
|
|
));
|
|
|
|
let local: Request =
|
|
serde_json::from_str(r#"{"SearchLocal":{"query":"old","limit":10}}"#).unwrap();
|
|
assert!(matches!(
|
|
local,
|
|
Request::SearchLocal { query, limit } if query == "old" && limit == 10
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn delete_album_keeps_photos_unless_asked() {
|
|
let request: Request = serde_json::from_str(r#"{"DeleteAlbum":{"uid":"v~a"}}"#).unwrap();
|
|
assert!(matches!(
|
|
request,
|
|
Request::DeleteAlbum { uid, delete_photos: false } if uid == "v~a"
|
|
));
|
|
}
|
|
|
|
/// The trash requests carry uids rather than paths; they must survive the same
|
|
/// line-delimited round-trip, since a mangled uid would restore or destroy the
|
|
/// wrong node.
|
|
#[test]
|
|
fn trash_requests_roundtrip() {
|
|
let reqs = [
|
|
Request::ListTrash,
|
|
Request::Restore {
|
|
uids: vec!["vol~link".into(), "vol~other".into()],
|
|
},
|
|
Request::DeleteForever {
|
|
uids: vec!["vol~link".into()],
|
|
},
|
|
Request::EmptyTrash,
|
|
Request::Refresh {
|
|
scope: RefreshScope::Dir { path: "a/b".into() },
|
|
},
|
|
Request::Refresh {
|
|
scope: RefreshScope::Dir {
|
|
path: String::new(),
|
|
},
|
|
},
|
|
Request::Refresh {
|
|
scope: RefreshScope::Trash,
|
|
},
|
|
Request::Refresh {
|
|
scope: RefreshScope::Photos { full: false },
|
|
},
|
|
Request::Refresh {
|
|
scope: RefreshScope::Photos { full: true },
|
|
},
|
|
];
|
|
for req in reqs {
|
|
let line = serde_json::to_string(&req).unwrap();
|
|
assert!(!line.contains('\n'), "wire form must be a single line");
|
|
let back: Request = serde_json::from_str(&line).unwrap();
|
|
assert_eq!(line, serde_json::to_string(&back).unwrap());
|
|
}
|
|
}
|
|
|
|
/// A place listing and a place page cross the wire intact: a mangled id
|
|
/// would open the wrong town.
|
|
#[test]
|
|
fn places_requests_and_reply_roundtrip() {
|
|
for req in [
|
|
Request::PhotoPlaces,
|
|
Request::PlacePhotos {
|
|
id: 2950159,
|
|
offset: 60,
|
|
limit: 30,
|
|
},
|
|
Request::PlaceLocations { id: 2950159 },
|
|
] {
|
|
let line = serde_json::to_string(&req).unwrap();
|
|
let back: Request = serde_json::from_str(&line).unwrap();
|
|
assert_eq!(line, serde_json::to_string(&back).unwrap());
|
|
}
|
|
|
|
let line = r#"{"Places":{"items":[{"id":2950159,"name":"Berlin","country":"DE","photo_count":2,
|
|
"cover":{"uid":"vol~b2","capture_time":1700000000,"thumb_path":null,"name":"b2.jpg","ratio":null,"no_thumb":false}}]}}"#;
|
|
let Response::Places { items, mapping } = serde_json::from_str(line).unwrap() else {
|
|
panic!("not a place listing");
|
|
};
|
|
assert_eq!(items.len(), 1);
|
|
assert_eq!((items[0].id, items[0].country.as_str()), (2950159, "DE"));
|
|
assert_eq!(items[0].cover.uid, "vol~b2");
|
|
// A daemon from before the progress field still parses: no mapping.
|
|
assert_eq!(mapping, None);
|
|
|
|
let reply = Response::Places {
|
|
items: Vec::new(),
|
|
mapping: Some(MappingProgress {
|
|
done: 120,
|
|
total: 480,
|
|
}),
|
|
};
|
|
let line = serde_json::to_string(&reply).unwrap();
|
|
let Response::Places { mapping, .. } = serde_json::from_str(&line).unwrap() else {
|
|
panic!("not a place listing");
|
|
};
|
|
assert_eq!(
|
|
mapping,
|
|
Some(MappingProgress {
|
|
done: 120,
|
|
total: 480
|
|
})
|
|
);
|
|
}
|
|
|
|
/// The sharing and devices requests must survive the same line-delimited JSON
|
|
/// round-trip: a mangled id or role would touch the wrong share or member.
|
|
#[test]
|
|
fn sharing_requests_roundtrip() {
|
|
let reqs = [
|
|
Request::ListLocations,
|
|
Request::ListDevices,
|
|
Request::RenameDevice {
|
|
uid: "dev-1".into(),
|
|
name: "laptop".into(),
|
|
},
|
|
Request::DeleteDevice {
|
|
uid: "dev-1".into(),
|
|
},
|
|
Request::AddSyncFolder {
|
|
local_path: "/home/me/Docs".into(),
|
|
},
|
|
Request::ListSyncFolders,
|
|
Request::RemoveSyncFolder {
|
|
id: 3,
|
|
delete_remote: true,
|
|
},
|
|
Request::SetSyncFolderMode {
|
|
id: 3,
|
|
mode: "ondemand".into(),
|
|
},
|
|
Request::SetSyncFolderPaused {
|
|
id: 3,
|
|
paused: true,
|
|
},
|
|
Request::SetBandwidthLimits {
|
|
upload: 1_000_000,
|
|
download: 0,
|
|
},
|
|
Request::SyncNow { id: Some(3) },
|
|
Request::AdoptDevice {
|
|
uid: Some("dev-1".into()),
|
|
},
|
|
Request::AdoptDevice { uid: None },
|
|
Request::ListRestorableFolders,
|
|
Request::RestoreSyncFolders {
|
|
items: vec![RestoreItem {
|
|
remote_uid: "vol~link".into(),
|
|
local_path: "/home/me/Docs".into(),
|
|
mode: "mirror".into(),
|
|
}],
|
|
},
|
|
Request::ListDeviceRestorableFolders {
|
|
device: "dev-2".into(),
|
|
},
|
|
Request::RestoreDeviceFolders {
|
|
device: "dev-2".into(),
|
|
items: vec![RestoreItem {
|
|
remote_uid: "vol~link".into(),
|
|
local_path: "/home/me/Work".into(),
|
|
mode: "ondemand".into(),
|
|
}],
|
|
},
|
|
Request::ShareNode {
|
|
path: "a/b".into(),
|
|
emails: vec!["x@proton.me".into(), "y@example.com".into()],
|
|
role: "editor".into(),
|
|
message: Some("hi".into()),
|
|
},
|
|
Request::ShareNodeByUid {
|
|
uid: "vol~link".into(),
|
|
emails: vec!["x@proton.me".into(), "y@example.com".into()],
|
|
role: "editor".into(),
|
|
message: Some("hi".into()),
|
|
},
|
|
Request::ListShare { path: "a/b".into() },
|
|
Request::ListShareByUid {
|
|
uid: "vol~link".into(),
|
|
},
|
|
Request::UpdateShareRole {
|
|
path: "a/b".into(),
|
|
id: "mid-1".into(),
|
|
kind: ShareEntryKind::Member,
|
|
role: "admin".into(),
|
|
},
|
|
Request::UpdateShareRoleByUid {
|
|
uid: "vol~link".into(),
|
|
id: "mid-1".into(),
|
|
kind: ShareEntryKind::Member,
|
|
role: "admin".into(),
|
|
},
|
|
Request::RemoveShareEntry {
|
|
path: "a/b".into(),
|
|
id: "iid-1".into(),
|
|
kind: ShareEntryKind::ExternalInvite,
|
|
},
|
|
Request::RemoveShareEntryByUid {
|
|
uid: "vol~link".into(),
|
|
id: "iid-1".into(),
|
|
kind: ShareEntryKind::ExternalInvite,
|
|
},
|
|
Request::CreatePublicLink {
|
|
path: "a/b".into(),
|
|
role: "viewer".into(),
|
|
password: Some("pw".into()),
|
|
expires: Some(1_700_000_000),
|
|
},
|
|
Request::CreatePublicLinkByUid {
|
|
uid: "vol~link".into(),
|
|
role: "viewer".into(),
|
|
password: Some("pw".into()),
|
|
expires: Some(1_700_000_000),
|
|
},
|
|
Request::RemovePublicLink {
|
|
path: "a/b".into(),
|
|
id: "url-1".into(),
|
|
},
|
|
Request::RemovePublicLinkByUid {
|
|
uid: "vol~link".into(),
|
|
id: "url-1".into(),
|
|
},
|
|
Request::ListSharedByMe,
|
|
Request::ListActivity { limit: 100 },
|
|
Request::ListSharedWithMe,
|
|
Request::ListSharedFolder {
|
|
uid: "vol~link".into(),
|
|
},
|
|
Request::OpenSharedFile {
|
|
uid: "vol~link".into(),
|
|
},
|
|
Request::LeaveShared {
|
|
uid: "vol~link".into(),
|
|
},
|
|
Request::ListInvitations,
|
|
Request::AcceptInvitation { id: "inv-1".into() },
|
|
Request::RejectInvitation { id: "inv-1".into() },
|
|
Request::ListBookmarks,
|
|
Request::CreateBookmark {
|
|
url: "https://drive.proton.me/urls/tok#pw".into(),
|
|
password: None,
|
|
},
|
|
Request::DeleteBookmark {
|
|
token: "tok".into(),
|
|
},
|
|
Request::AccountQuota,
|
|
];
|
|
for req in reqs {
|
|
let line = serde_json::to_string(&req).unwrap();
|
|
assert!(!line.contains('\n'), "wire form must be a single line");
|
|
let back: Request = serde_json::from_str(&line).unwrap();
|
|
assert_eq!(line, serde_json::to_string(&back).unwrap());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn locations_response_roundtrips_typed_mounts() {
|
|
let response = Response::Locations {
|
|
items: vec![
|
|
MountSpec {
|
|
id: 1,
|
|
kind: MountKind::MyFiles,
|
|
local_path: "/home/me/ProtonDrive".into(),
|
|
root_uid: "vol~root".into(),
|
|
root_share_id: "share-main".into(),
|
|
mode: MountMode::OnDemand,
|
|
access: MountAccess::Rw,
|
|
state: "idle".into(),
|
|
last_sync: 0,
|
|
pending_mode: None,
|
|
mounted: true,
|
|
progress: None,
|
|
paused: false,
|
|
},
|
|
MountSpec {
|
|
id: 2,
|
|
kind: MountKind::Device { sync_folder_id: 7 },
|
|
local_path: "/home/me/Work".into(),
|
|
root_uid: "device-vol~work".into(),
|
|
root_share_id: "device-share".into(),
|
|
mode: MountMode::Mirror,
|
|
access: MountAccess::Rw,
|
|
state: "syncing".into(),
|
|
last_sync: 42,
|
|
pending_mode: Some(MountMode::OnDemand),
|
|
mounted: false,
|
|
progress: None,
|
|
paused: false,
|
|
},
|
|
MountSpec {
|
|
id: 3,
|
|
kind: MountKind::Shared {
|
|
share_root_uid: "shared-vol~root".into(),
|
|
},
|
|
local_path: "/home/me/Shared".into(),
|
|
root_uid: "shared-vol~root".into(),
|
|
root_share_id: "shared-share".into(),
|
|
mode: MountMode::Unknown,
|
|
access: MountAccess::Ro,
|
|
state: "error".into(),
|
|
last_sync: 0,
|
|
pending_mode: Some(MountMode::Unknown),
|
|
mounted: false,
|
|
progress: Some(SyncProgress {
|
|
phase: SyncPhase::Applying,
|
|
done: 2,
|
|
total: 5,
|
|
current: "report.pdf".into(),
|
|
}),
|
|
paused: true,
|
|
},
|
|
],
|
|
};
|
|
let wire = serde_json::to_string(&response).unwrap();
|
|
assert!(!wire.contains('\n'), "wire form must be a single line");
|
|
assert!(wire.contains(r#""mode":"ondemand""#));
|
|
assert!(wire.contains(r#""kind":"shared""#));
|
|
assert!(wire.contains(r#""access":"ro""#));
|
|
assert!(wire.contains(r#""mode":"unknown""#));
|
|
let back: Response = serde_json::from_str(&wire).unwrap();
|
|
match back {
|
|
Response::Locations { items } => {
|
|
assert_eq!(items.len(), 3);
|
|
assert!(matches!(
|
|
items[1].kind,
|
|
MountKind::Device { sync_folder_id: 7 }
|
|
));
|
|
assert_eq!(items[1].pending_mode, Some(MountMode::OnDemand));
|
|
assert!(matches!(
|
|
&items[2].kind,
|
|
MountKind::Shared { share_root_uid }
|
|
if share_root_uid == "shared-vol~root"
|
|
));
|
|
assert_eq!(items[2].access, MountAccess::Ro);
|
|
assert_eq!(items[2].mode, MountMode::Unknown);
|
|
assert_eq!(items[2].pending_mode, Some(MountMode::Unknown));
|
|
let progress = items[2].progress.as_ref().unwrap();
|
|
assert_eq!(progress.phase, SyncPhase::Applying);
|
|
assert_eq!((progress.done, progress.total), (2, 5));
|
|
}
|
|
other => panic!("unexpected response: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_daemons_reject_list_locations_cleanly() {
|
|
#[allow(dead_code)]
|
|
#[derive(serde::Deserialize)]
|
|
enum LegacyLocationRequest {
|
|
ListSyncFolders,
|
|
}
|
|
|
|
let wire = serde_json::to_string(&Request::ListLocations).unwrap();
|
|
assert!(
|
|
serde_json::from_str::<LegacyLocationRequest>(&wire).is_err(),
|
|
"an old daemon must reject ListLocations instead of interpreting another request"
|
|
);
|
|
}
|
|
|
|
/// 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"}}"#,
|
|
),
|
|
(
|
|
Request::ExportPendingOp {
|
|
id: 7,
|
|
dest: "/tmp/out.bin".into(),
|
|
},
|
|
r#"{"ExportPendingOp":{"id":7,"dest":"/tmp/out.bin"}}"#,
|
|
),
|
|
(
|
|
Request::DiscardPendingOp { id: 7 },
|
|
r#"{"DiscardPendingOp":{"id":7}}"#,
|
|
),
|
|
];
|
|
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 daemon that predates sync issues sends neither field, and a newer one
|
|
/// may send an issue this build does not know.
|
|
#[test]
|
|
fn a_queued_op_from_another_daemon_version_still_decodes() {
|
|
let old = r#"{"id":1,"kind":"create","path":"a.txt","attempts":0,"last_error":null,
|
|
"queued_at":5,"next_attempt_at":5,"parked":false,"failing":false}"#;
|
|
let op: PendingOpInfo = serde_json::from_str(old).unwrap();
|
|
assert_eq!(op.issue, None);
|
|
assert!(!op.exportable);
|
|
|
|
let newer = old.replace(r#""failing":false"#, r#""failing":true,"issue":"embargo""#);
|
|
let op: PendingOpInfo = serde_json::from_str(&newer).unwrap();
|
|
assert_eq!(op.issue, Some(SyncIssue::Unknown));
|
|
assert_eq!(
|
|
SyncIssue::parse(SyncIssue::Quota.as_str()),
|
|
SyncIssue::Quota
|
|
);
|
|
}
|
|
|
|
/// A front-end that predates the favorites 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,
|
|
not_in_album: 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 = [
|
|
(
|
|
Request::ShareNode {
|
|
path: "a/b".into(),
|
|
emails: vec!["x@example.com".into()],
|
|
role: "viewer".into(),
|
|
message: None,
|
|
},
|
|
r#"{"ShareNode":{"path":"a/b","emails":["x@example.com"],"role":"viewer","message":null}}"#,
|
|
),
|
|
(
|
|
Request::ListShare { path: "a/b".into() },
|
|
r#"{"ListShare":{"path":"a/b"}}"#,
|
|
),
|
|
(
|
|
Request::UpdateShareRole {
|
|
path: "a/b".into(),
|
|
id: "member".into(),
|
|
kind: ShareEntryKind::Member,
|
|
role: "editor".into(),
|
|
},
|
|
r#"{"UpdateShareRole":{"path":"a/b","id":"member","kind":"Member","role":"editor"}}"#,
|
|
),
|
|
(
|
|
Request::RemoveShareEntry {
|
|
path: "a/b".into(),
|
|
id: "invite".into(),
|
|
kind: ShareEntryKind::ProtonInvite,
|
|
},
|
|
r#"{"RemoveShareEntry":{"path":"a/b","id":"invite","kind":"ProtonInvite"}}"#,
|
|
),
|
|
(
|
|
Request::CreatePublicLink {
|
|
path: "a/b".into(),
|
|
role: "viewer".into(),
|
|
password: None,
|
|
expires: None,
|
|
},
|
|
r#"{"CreatePublicLink":{"path":"a/b","role":"viewer","password":null,"expires":null}}"#,
|
|
),
|
|
(
|
|
Request::RemovePublicLink {
|
|
path: "a/b".into(),
|
|
id: "link".into(),
|
|
},
|
|
r#"{"RemovePublicLink":{"path":"a/b","id":"link"}}"#,
|
|
),
|
|
];
|
|
|
|
for (request, expected) in cases {
|
|
assert_eq!(serde_json::to_string(&request).unwrap(), expected);
|
|
assert!(
|
|
!expected.contains("\"uid\""),
|
|
"path variants must never acquire an optional uid"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn by_uid_share_requests_are_distinct_wire_variants() {
|
|
let cases = [
|
|
(
|
|
Request::ShareNodeByUid {
|
|
uid: "vol~link".into(),
|
|
emails: vec!["x@example.com".into()],
|
|
role: "viewer".into(),
|
|
message: None,
|
|
},
|
|
"ShareNodeByUid",
|
|
),
|
|
(
|
|
Request::ListShareByUid {
|
|
uid: "vol~link".into(),
|
|
},
|
|
"ListShareByUid",
|
|
),
|
|
(
|
|
Request::UpdateShareRoleByUid {
|
|
uid: "vol~link".into(),
|
|
id: "member".into(),
|
|
kind: ShareEntryKind::Member,
|
|
role: "editor".into(),
|
|
},
|
|
"UpdateShareRoleByUid",
|
|
),
|
|
(
|
|
Request::RemoveShareEntryByUid {
|
|
uid: "vol~link".into(),
|
|
id: "invite".into(),
|
|
kind: ShareEntryKind::ProtonInvite,
|
|
},
|
|
"RemoveShareEntryByUid",
|
|
),
|
|
(
|
|
Request::CreatePublicLinkByUid {
|
|
uid: "vol~link".into(),
|
|
role: "viewer".into(),
|
|
password: None,
|
|
expires: None,
|
|
},
|
|
"CreatePublicLinkByUid",
|
|
),
|
|
(
|
|
Request::RemovePublicLinkByUid {
|
|
uid: "vol~link".into(),
|
|
id: "link".into(),
|
|
},
|
|
"RemovePublicLinkByUid",
|
|
),
|
|
(
|
|
Request::StopSharingByUid {
|
|
uid: "vol~link".into(),
|
|
},
|
|
"StopSharingByUid",
|
|
),
|
|
];
|
|
|
|
for (request, variant) in cases {
|
|
let wire = serde_json::to_string(&request).unwrap();
|
|
let object = serde_json::from_str::<serde_json::Value>(&wire)
|
|
.unwrap()
|
|
.as_object()
|
|
.cloned()
|
|
.unwrap();
|
|
assert_eq!(object.len(), 1);
|
|
assert!(object.contains_key(variant));
|
|
let decoded = serde_json::from_str::<Request>(&wire).unwrap();
|
|
let decoded_uid = match &decoded {
|
|
Request::ShareNodeByUid { uid, .. }
|
|
| Request::ListShareByUid { uid }
|
|
| Request::UpdateShareRoleByUid { uid, .. }
|
|
| Request::RemoveShareEntryByUid { uid, .. }
|
|
| Request::CreatePublicLinkByUid { uid, .. }
|
|
| Request::RemovePublicLinkByUid { uid, .. }
|
|
| Request::StopSharingByUid { uid } => uid,
|
|
other => panic!("decoded into the wrong dispatch variant: {other:?}"),
|
|
};
|
|
assert_eq!(decoded_uid, "vol~link");
|
|
assert_eq!(serde_json::to_string(&decoded).unwrap(), wire);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn open_file_accepts_a_request_from_a_client_predating_the_uid_field() {
|
|
let legacy = r#"{"OpenFile":{"path":"Documents/ONBOARDING.md"}}"#;
|
|
let Request::OpenFile { path, uid } = serde_json::from_str::<Request>(legacy).unwrap()
|
|
else {
|
|
panic!("decoded into the wrong variant");
|
|
};
|
|
assert_eq!(path, "Documents/ONBOARDING.md");
|
|
assert_eq!(uid, None);
|
|
|
|
let with_uid = serde_json::to_string(&Request::OpenFile {
|
|
path: "Documents/ONBOARDING.md".into(),
|
|
uid: Some("vol~link".into()),
|
|
})
|
|
.unwrap();
|
|
assert!(with_uid.contains("vol~link"));
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_daemons_reject_new_by_uid_variants() {
|
|
#[allow(dead_code)]
|
|
#[derive(serde::Deserialize)]
|
|
enum LegacyShareRequest {
|
|
ShareNode {
|
|
path: String,
|
|
emails: Vec<String>,
|
|
role: String,
|
|
message: Option<String>,
|
|
},
|
|
ListShare {
|
|
path: String,
|
|
},
|
|
UpdateShareRole {
|
|
path: String,
|
|
id: String,
|
|
kind: ShareEntryKind,
|
|
role: String,
|
|
},
|
|
RemoveShareEntry {
|
|
path: String,
|
|
id: String,
|
|
kind: ShareEntryKind,
|
|
},
|
|
CreatePublicLink {
|
|
path: String,
|
|
role: String,
|
|
password: Option<String>,
|
|
expires: Option<i64>,
|
|
},
|
|
RemovePublicLink {
|
|
path: String,
|
|
id: String,
|
|
},
|
|
}
|
|
|
|
let requests = [
|
|
Request::ShareNodeByUid {
|
|
uid: "vol~link".into(),
|
|
emails: Vec::new(),
|
|
role: "viewer".into(),
|
|
message: None,
|
|
},
|
|
Request::ListShareByUid {
|
|
uid: "vol~link".into(),
|
|
},
|
|
Request::UpdateShareRoleByUid {
|
|
uid: "vol~link".into(),
|
|
id: "member".into(),
|
|
kind: ShareEntryKind::Member,
|
|
role: "viewer".into(),
|
|
},
|
|
Request::RemoveShareEntryByUid {
|
|
uid: "vol~link".into(),
|
|
id: "member".into(),
|
|
kind: ShareEntryKind::Member,
|
|
},
|
|
Request::CreatePublicLinkByUid {
|
|
uid: "vol~link".into(),
|
|
role: "viewer".into(),
|
|
password: None,
|
|
expires: None,
|
|
},
|
|
Request::RemovePublicLinkByUid {
|
|
uid: "vol~link".into(),
|
|
id: "link".into(),
|
|
},
|
|
];
|
|
|
|
for request in requests {
|
|
let wire = serde_json::to_string(&request).unwrap();
|
|
assert!(
|
|
serde_json::from_str::<LegacyShareRequest>(&wire).is_err(),
|
|
"an old daemon must reject {wire}, never reinterpret it as a path request"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A daemon built before `kind` existed sends `Error` without the field.
|
|
/// It must still parse, and land on the class that promises the least.
|
|
#[test]
|
|
fn an_error_without_a_kind_reads_as_internal() {
|
|
let wire = r#"{"Error":{"message":"something broke"}}"#;
|
|
let back: Response = serde_json::from_str(wire).unwrap();
|
|
match back {
|
|
Response::Error { message, kind } => {
|
|
assert_eq!(message, "something broke");
|
|
assert_eq!(kind, ErrorKind::Internal);
|
|
}
|
|
other => panic!("expected an error, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn error_kind_survives_the_wire() {
|
|
for kind in [
|
|
ErrorKind::Offline,
|
|
ErrorKind::NotFound,
|
|
ErrorKind::Denied,
|
|
ErrorKind::Conflict,
|
|
ErrorKind::Invalid,
|
|
ErrorKind::Remote,
|
|
ErrorKind::Internal,
|
|
] {
|
|
let line = serde_json::to_string(&Response::Error {
|
|
message: "x".into(),
|
|
kind,
|
|
})
|
|
.unwrap();
|
|
let back: Response = serde_json::from_str(&line).unwrap();
|
|
match back {
|
|
Response::Error { kind: got, .. } => assert_eq!(got, kind),
|
|
other => panic!("expected an error, got {other:?}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Retry is offered to the user off the back of this, so it has to mean
|
|
/// "an identical request could work", not "this looks recoverable".
|
|
#[test]
|
|
fn only_offline_and_remote_are_worth_retrying() {
|
|
assert!(ErrorKind::Offline.retryable());
|
|
assert!(ErrorKind::Remote.retryable());
|
|
for kind in [
|
|
ErrorKind::NotFound,
|
|
ErrorKind::Denied,
|
|
ErrorKind::Conflict,
|
|
ErrorKind::Invalid,
|
|
ErrorKind::Internal,
|
|
] {
|
|
assert!(!kind.retryable(), "{kind:?} must not offer a retry");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn response_error_carries_a_core_errors_classification() {
|
|
let r = Response::error(crate::error::CoreError::not_found("no such file"));
|
|
match r {
|
|
Response::Error { message, kind } => {
|
|
assert_eq!(message, "no such file");
|
|
assert_eq!(kind, ErrorKind::NotFound);
|
|
}
|
|
other => panic!("expected an error, got {other:?}"),
|
|
}
|
|
}
|
|
}
|