mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 08:56:59 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d76462e62 | ||
|
|
a5e03ad8da | ||
|
|
2336ad9e87 | ||
|
|
d988236bfa | ||
|
|
d78ddeab8e | ||
|
|
b04cce3a23 | ||
|
|
7e8403936b | ||
|
|
866a174c7a | ||
|
|
352117e806 | ||
|
|
4b5666eacb | ||
|
|
9bbd8eb982 | ||
|
|
77198ed261 | ||
|
|
0d79c46729 | ||
|
|
a8210ce8fd | ||
|
|
551e14b644 | ||
|
|
7de667b386 | ||
|
|
295bbeadac | ||
|
|
32817870a5 | ||
|
|
b93e41efc5 | ||
|
|
082ef8522a |
No files matched your search
@@ -13,6 +13,7 @@ jobs:
|
||||
build-release:
|
||||
name: Build and Release
|
||||
runs-on: ubuntu-24.04
|
||||
environment: 'rpm signing'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -154,6 +155,26 @@ jobs:
|
||||
# Copy built RPM to root directory
|
||||
cp rpmbuild/RPMS/x86_64/*.rpm .
|
||||
|
||||
- name: Sign Fedora package (.rpm)
|
||||
env:
|
||||
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
GPG_KEY_NAME: ${{ secrets.GPG_KEY_NAME }}
|
||||
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
|
||||
run: |
|
||||
if [ -n "$GPG_PRIVATE_KEY" ] && [ -n "$GPG_KEY_NAME" ]; then
|
||||
echo "$GPG_PRIVATE_KEY" | gpg --batch --pinentry-mode loopback --passphrase "$GPG_PASSPHRASE" --import
|
||||
|
||||
cat <<EOF > ~/.rpmmacros
|
||||
%_signature gpg
|
||||
%_gpg_name ${GPG_KEY_NAME}
|
||||
%__gpg_sign_cmd %{__gpg} gpg --batch --verbose --no-armor --pinentry-mode loopback --passphrase %{_gpg_passphrase} --no-secmem-warning -u "%{_gpg_name}" -sbo %{__signature_filename} --digest-algo sha256 %{__plaintext_filename}
|
||||
EOF
|
||||
|
||||
rpm --addsign --define "_gpg_passphrase $GPG_PASSPHRASE" *.rpm
|
||||
else
|
||||
echo "Skipping RPM signing: GPG_PRIVATE_KEY or GPG_KEY_NAME secret is not set."
|
||||
fi
|
||||
|
||||
|
||||
- name: Upload Workflow Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
Generated
+10
-8
@@ -3518,7 +3518,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-cli"
|
||||
version = "1.1.0"
|
||||
version = "1.4.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap 4.6.4",
|
||||
@@ -3533,8 +3533,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-core"
|
||||
version = "1.1.0"
|
||||
version = "1.4.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"directories",
|
||||
"ignore",
|
||||
"keyring",
|
||||
@@ -3553,9 +3554,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-fuse"
|
||||
version = "1.1.0"
|
||||
version = "1.4.0"
|
||||
dependencies = [
|
||||
"fuser",
|
||||
"futures",
|
||||
"image",
|
||||
"libc",
|
||||
"notify",
|
||||
@@ -3569,7 +3571,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-gui"
|
||||
version = "1.1.0"
|
||||
version = "1.4.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3830,9 +3832,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-drive-rs"
|
||||
version = "0.2.2"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "692610fbead83735a0415d9d068c1e9b467b12cce013ea19b3063f05206ce3d6"
|
||||
checksum = "3abae5e90e6889b120208159eb2c60b332612e5b708fdf3c14287307ce01715d"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
@@ -3857,9 +3859,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-sdk"
|
||||
version = "0.2.2"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b3f2ad9cd1ba69b67cbafcaf7c159b3a200162a3c518b1bc7e25df837343b41"
|
||||
checksum = "3fa729310c1a66e12bb41b9f5f036c86b9857e76536556052203ffecdb0b44c3"
|
||||
dependencies = [
|
||||
"aes-gcm 0.11.0",
|
||||
"async-trait",
|
||||
|
||||
+5
-3
@@ -3,15 +3,15 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.1.0"
|
||||
version = "1.4.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
rust-version = "1.96"
|
||||
|
||||
[workspace.dependencies]
|
||||
proton-sdk = { version = "0.2.2" }
|
||||
proton-drive-rs = { version = "0.2.2" }
|
||||
proton-sdk = { version = "0.5.1" }
|
||||
proton-drive-rs = { version = "0.5.1" }
|
||||
|
||||
# Internal crates.
|
||||
pdfs-core = { path = "crates/pdfs-core" }
|
||||
@@ -40,5 +40,7 @@ rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
rpassword = "7"
|
||||
async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
fuser = "0.17"
|
||||
libc = "0.2"
|
||||
@@ -9,10 +9,14 @@ A fast, unofficial Proton Drive client for Linux. This client features an advanc
|
||||
- **Non-Blocking GTK4 Desktop App**: Browse files, manage pins, configure options, and monitor active transfers through a modern, native GUI with a fully non-blocking asynchronous main loop.
|
||||
- **Shared Links & Invites**: Browse files shared with you by other users, and view/manage your own public shared links directly in the GUI.
|
||||
- **Local Backup (Computers)**: Sync and back up local directories (like Downloads, Documents, Pictures, etc.) directly to your Proton Drive account.
|
||||
- **Locations**: One page (and `pdfs locations`) listing every place Proton Drive occupies on this machine — the main mount plus each backed-up folder — with its mode, sync state, and whether it is read-only. Switching a folder between a full local copy and on-demand happens here.
|
||||
- **System Tray Integration**: Background indicator for status monitoring, quick actions, and fast sync controls.
|
||||
- **Unified Search Launcher (HUD)**: A resident Google Drive-style launcher (`pdfs-prompt`) that searches Proton Drive and local files together, ranks the best matches, tolerates abbreviations and typos, and is ideal for a system-wide hotkey.
|
||||
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages.
|
||||
- **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI).
|
||||
- **File Version History**: Every revision Proton Drive still holds for a file, from the browser's details pane (**Versions**) or `pdfs versions list|restore|save|rm`. Restoring is server-side — no re-upload — and an old version can be written out to a local file without touching the live one.
|
||||
- **Photo Favourites**: Star a photo in the lightbox, filter the gallery to favourites, or use `pdfs favorite <uid>` / `pdfs photos --favorites`.
|
||||
- **Photo Albums**: Browse your albums — including the ones shared with you — from the Albums view of the Photos page, or with `pdfs albums` / `pdfs album <uid>`. Album contents open in the same gallery as the timeline.
|
||||
- **Human Verification (CAPTCHA) Recovery**: Detects sign-in gates (VPN/new IP challenges) and launches an embedded `WebKitWebView` dialog to safely complete the challenge, transparently retrying authentication with the earned token.
|
||||
- **Selective Sync (`.pdfsignore`)**: Keep build trees, dependency directories, and editor leftovers out of synced folders using gitignore-style rules.
|
||||
- **Data-Safe Offline Writes**: Durable scratch/staging files and a transactional pending queue preserve acknowledged writes across network failures and restarts.
|
||||
|
||||
+258
-22
@@ -13,7 +13,7 @@ use pdfs_core::auth;
|
||||
use pdfs_core::cache::ContentCache;
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
ErrorKind, RefreshScope, Request as CtlRequest, Response as CtlResponse,
|
||||
ErrorKind, MountKind, RefreshScope, Request as CtlRequest, Response as CtlResponse,
|
||||
RestoreItem as CtlRestoreItem, ShareEntryKind, SyncPhase, pending_summary,
|
||||
};
|
||||
use pdfs_core::db::Db;
|
||||
@@ -28,7 +28,7 @@ struct Cli {
|
||||
/// Emit machine-readable JSON instead of formatted text.
|
||||
///
|
||||
/// Applies to the query commands (`status`, `ls`, `pins`, `sync list`,
|
||||
/// `devices list`, `transfers`, `activity`, `cache inspect`). Commands that
|
||||
/// `devices list`, `locations`, `transfers`, `activity`, `cache inspect`). Commands that
|
||||
/// perform an action keep their human output — a script that needs to know
|
||||
/// whether one succeeded has the exit code.
|
||||
#[arg(long, global = true)]
|
||||
@@ -105,6 +105,8 @@ enum Command {
|
||||
Logout,
|
||||
/// Show account login state, and mount status if a daemon is running.
|
||||
Status,
|
||||
/// List every local Proton Drive location.
|
||||
Locations,
|
||||
/// Mount Proton Drive at the given (or default) path. Blocks until unmounted.
|
||||
Mount {
|
||||
/// Mountpoint; defaults to ~/ProtonDrive.
|
||||
@@ -142,6 +144,30 @@ enum Command {
|
||||
/// Skip this many photos from the start of the timeline.
|
||||
#[arg(long, default_value_t = 0)]
|
||||
offset: usize,
|
||||
/// List only favourited photos.
|
||||
#[arg(long)]
|
||||
favorites: bool,
|
||||
},
|
||||
/// Add or remove a photo's favourite mark.
|
||||
Favorite {
|
||||
/// Photo node uid in `volume~link` form (from `pdfs photos`).
|
||||
uid: String,
|
||||
/// Remove the mark instead of adding it.
|
||||
#[arg(long)]
|
||||
remove: bool,
|
||||
},
|
||||
/// List the photo albums (including ones shared with this account).
|
||||
Albums,
|
||||
/// List the photos in one album (newest capture first).
|
||||
Album {
|
||||
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||
uid: String,
|
||||
/// Max photos to list.
|
||||
#[arg(long, default_value_t = 50)]
|
||||
limit: usize,
|
||||
/// Skip this many photos from the start of the album.
|
||||
#[arg(long, default_value_t = 0)]
|
||||
offset: usize,
|
||||
},
|
||||
/// Download a photo by uid (`volume~link`) and print its cached path.
|
||||
OpenPhoto {
|
||||
@@ -271,6 +297,11 @@ enum Command {
|
||||
/// Entry kind: member, proton or external.
|
||||
kind: ShareKindArg,
|
||||
},
|
||||
/// Inspect and restore a file's earlier versions.
|
||||
Versions {
|
||||
#[command(subcommand)]
|
||||
action: VersionCmd,
|
||||
},
|
||||
/// Manage a node's public link.
|
||||
PublicLink {
|
||||
#[command(subcommand)]
|
||||
@@ -365,6 +396,43 @@ impl ShareKindArg {
|
||||
}
|
||||
}
|
||||
|
||||
/// `pdfs versions …` — a file's revision history.
|
||||
#[derive(Subcommand)]
|
||||
enum VersionCmd {
|
||||
/// List a file's versions, newest first.
|
||||
List {
|
||||
/// File path, inside the mountpoint or relative to it.
|
||||
path: PathBuf,
|
||||
},
|
||||
/// Make an earlier version the file's current content.
|
||||
///
|
||||
/// The server applies this in the background, so the new content may take a
|
||||
/// moment to show up in the mount.
|
||||
Restore {
|
||||
/// File path, inside the mountpoint or relative to it.
|
||||
path: PathBuf,
|
||||
/// Version id, from `pdfs versions list`.
|
||||
id: String,
|
||||
},
|
||||
/// Write an earlier version to a local file, leaving the file untouched.
|
||||
Save {
|
||||
/// File path, inside the mountpoint or relative to it.
|
||||
path: PathBuf,
|
||||
/// Version id, from `pdfs versions list`.
|
||||
id: String,
|
||||
/// Where to write it. Must not already exist.
|
||||
dest: PathBuf,
|
||||
},
|
||||
/// Permanently delete an earlier version. The content is unrecoverable, and
|
||||
/// the version that is currently active cannot be deleted.
|
||||
Rm {
|
||||
/// File path, inside the mountpoint or relative to it.
|
||||
path: PathBuf,
|
||||
/// Version id, from `pdfs versions list`.
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum DeviceCmd {
|
||||
/// List registered devices.
|
||||
@@ -507,13 +575,21 @@ fn main() -> Result<()> {
|
||||
Command::Login { username } => cmd_login(username),
|
||||
Command::Logout => cmd_logout(),
|
||||
Command::Status => cmd_status(),
|
||||
Command::Locations => cmd_locations(),
|
||||
Command::Mount { mountpoint } => cmd_mount(mountpoint),
|
||||
Command::Daemon { mountpoint } => cmd_daemon(mountpoint),
|
||||
Command::Pin { path } => cmd_pin(path),
|
||||
Command::Unpin { path } => cmd_unpin(path),
|
||||
Command::Pins => cmd_pins(),
|
||||
Command::Ls { path } => cmd_ls(path),
|
||||
Command::Photos { limit, offset } => cmd_photos(limit, offset),
|
||||
Command::Photos {
|
||||
limit,
|
||||
offset,
|
||||
favorites,
|
||||
} => cmd_photos(limit, offset, favorites),
|
||||
Command::Favorite { uid, remove } => cmd_favorite(uid, remove),
|
||||
Command::Albums => cmd_albums(),
|
||||
Command::Album { uid, limit, offset } => cmd_album(uid, limit, offset),
|
||||
Command::OpenPhoto { uid } => cmd_open_photo(uid),
|
||||
Command::Search { query, limit } => cmd_search(query, limit),
|
||||
Command::Rename { path, new_name } => cmd_rename(path, new_name),
|
||||
@@ -543,6 +619,7 @@ fn main() -> Result<()> {
|
||||
role,
|
||||
} => cmd_share_role(path, id, kind, role),
|
||||
Command::Unshare { path, id, kind } => cmd_unshare(path, id, kind),
|
||||
Command::Versions { action } => cmd_versions(action),
|
||||
Command::PublicLink { action } => cmd_public_link(action),
|
||||
Command::Shared => cmd_shared(),
|
||||
Command::SharedWithMe { uid } => cmd_shared_with_me(uid),
|
||||
@@ -770,6 +847,71 @@ fn cmd_share(
|
||||
})?)
|
||||
}
|
||||
|
||||
fn cmd_versions(action: VersionCmd) -> Result<()> {
|
||||
match action {
|
||||
VersionCmd::List { path } => {
|
||||
let response = control_request(CtlRequest::ListRevisions {
|
||||
path: path_arg(&path)?,
|
||||
})?;
|
||||
if emit_json(&response)? {
|
||||
return Ok(());
|
||||
}
|
||||
match response {
|
||||
CtlResponse::Revisions { items } if items.is_empty() => {
|
||||
println!("No versions.");
|
||||
}
|
||||
CtlResponse::Revisions { items } => {
|
||||
for r in items {
|
||||
// The claimed plaintext size is what the user recognises;
|
||||
// the storage size is ciphertext and always larger.
|
||||
let size = r
|
||||
.claimed_size
|
||||
.filter(|size| *size >= 0)
|
||||
.unwrap_or(r.size_on_storage)
|
||||
.max(0) as u64;
|
||||
let mark = if r.is_active { "*" } else { " " };
|
||||
let by = r
|
||||
.signed_by
|
||||
.map(|email| format!(" {email}"))
|
||||
.unwrap_or_default();
|
||||
println!(
|
||||
"{mark} {:<20} {:>10} {}{by}",
|
||||
r.id,
|
||||
human_bytes(size),
|
||||
format_epoch(r.created),
|
||||
);
|
||||
}
|
||||
println!("(* = current)");
|
||||
}
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
VersionCmd::Restore { path, id } => {
|
||||
ok_or_bail(control_request(CtlRequest::RestoreRevision {
|
||||
path: path_arg(&path)?,
|
||||
revision_id: id,
|
||||
})?)
|
||||
}
|
||||
VersionCmd::Save { path, id, dest } => {
|
||||
// The daemon writes the file, so it needs a path that means the same
|
||||
// thing in its process as in this one.
|
||||
let dest = std::path::absolute(&dest)
|
||||
.with_context(|| format!("resolve {}", dest.display()))?;
|
||||
ok_or_bail(control_request(CtlRequest::SaveRevisionAs {
|
||||
path: path_arg(&path)?,
|
||||
revision_id: id,
|
||||
dest: dest.to_string_lossy().into_owned(),
|
||||
})?)
|
||||
}
|
||||
VersionCmd::Rm { path, id } => ok_or_bail(control_request(CtlRequest::DeleteRevision {
|
||||
path: path_arg(&path)?,
|
||||
revision_id: id,
|
||||
})?),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_members(path: PathBuf) -> Result<()> {
|
||||
match control_request(CtlRequest::ListShare {
|
||||
path: path_arg(&path)?,
|
||||
@@ -902,7 +1044,15 @@ fn cmd_shared_with_me(uid: Option<String>) -> Result<()> {
|
||||
CtlResponse::Entries { entries } => {
|
||||
for e in entries {
|
||||
let kind = if e.is_dir { "d" } else { "-" };
|
||||
println!("{kind} {:>12} {} [{}]", e.size, e.name, e.uid);
|
||||
// My role decides whether this is writable at all, so it is
|
||||
// printed whenever the API reported one; an empty role prints
|
||||
// nothing rather than a guess.
|
||||
let role = if e.role.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" ({})", e.role)
|
||||
};
|
||||
println!("{kind} {:>12} {}{role} [{}]", e.size, e.name, e.uid);
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
@@ -1201,6 +1351,45 @@ fn cmd_status() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_locations() -> Result<()> {
|
||||
let response = control_request(CtlRequest::ListLocations)?;
|
||||
if emit_json(&response)? {
|
||||
return Ok(());
|
||||
}
|
||||
match response {
|
||||
CtlResponse::Locations { items } if items.is_empty() => {
|
||||
println!("No Proton Drive locations.");
|
||||
}
|
||||
CtlResponse::Locations { items } => {
|
||||
for location in items {
|
||||
let kind = match &location.kind {
|
||||
MountKind::MyFiles => "My Files".to_string(),
|
||||
MountKind::Device { sync_folder_id } => {
|
||||
format!("Device folder {sync_folder_id}")
|
||||
}
|
||||
MountKind::Shared { .. } => "Shared folder".to_string(),
|
||||
};
|
||||
let mode = match location.pending_mode {
|
||||
Some(pending) => format!("{} -> {pending}", location.mode),
|
||||
None => location.mode.to_string(),
|
||||
};
|
||||
let mounted = if location.mounted {
|
||||
"mounted"
|
||||
} else {
|
||||
"not mounted"
|
||||
};
|
||||
println!(
|
||||
"{kind}: {} ({mode}, {}, {}, {mounted})",
|
||||
location.local_path, location.access, location.state
|
||||
);
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_mount(mountpoint: Option<PathBuf>) -> Result<()> {
|
||||
mount_once(mountpoint)?;
|
||||
Ok(())
|
||||
@@ -1422,12 +1611,13 @@ fn cmd_ls(path: Option<PathBuf>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
|
||||
fn cmd_photos(limit: usize, offset: usize, favorites: bool) -> Result<()> {
|
||||
match control_request(CtlRequest::PhotosTimeline {
|
||||
offset,
|
||||
limit,
|
||||
kind: None,
|
||||
range: None,
|
||||
favorites,
|
||||
})? {
|
||||
CtlResponse::Photos {
|
||||
available: false, ..
|
||||
@@ -1435,24 +1625,60 @@ fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
|
||||
println!("This account has no photos volume.")
|
||||
}
|
||||
CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."),
|
||||
CtlResponse::Photos { items, .. } => {
|
||||
// The timeline reply only carries thumbnails already in the cache, so
|
||||
// pull the rest of the page's in one batch to print a path per photo.
|
||||
let uids: Vec<String> = items.iter().map(|p| p.uid.clone()).collect();
|
||||
let thumbs: HashMap<String, Option<String>> =
|
||||
match control_request(CtlRequest::PhotoThumbs { uids })? {
|
||||
CtlResponse::Thumbs { items } => {
|
||||
items.into_iter().map(|t| (t.uid, t.path)).collect()
|
||||
}
|
||||
_ => HashMap::new(),
|
||||
CtlResponse::Photos { items, .. } => print_photo_page(items)?,
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Print one page of photos as `capture-time uid thumbnail-path`.
|
||||
///
|
||||
/// A timeline or album reply only carries thumbnails already in the cache, so
|
||||
/// the rest of the page's are pulled in one batch rather than left blank.
|
||||
fn cmd_favorite(uid: String, remove: bool) -> Result<()> {
|
||||
ok_or_bail(control_request(CtlRequest::SetPhotoFavorite {
|
||||
uid,
|
||||
favorite: !remove,
|
||||
})?)
|
||||
}
|
||||
|
||||
fn print_photo_page(items: Vec<pdfs_core::control::PhotoItem>) -> Result<()> {
|
||||
let uids: Vec<String> = items.iter().map(|p| p.uid.clone()).collect();
|
||||
let thumbs: HashMap<String, Option<String>> =
|
||||
match control_request(CtlRequest::PhotoThumbs { uids })? {
|
||||
CtlResponse::Thumbs { items } => items.into_iter().map(|t| (t.uid, t.path)).collect(),
|
||||
_ => HashMap::new(),
|
||||
};
|
||||
for p in items {
|
||||
let thumb = thumbs
|
||||
.get(&p.uid)
|
||||
.and_then(|p| p.as_deref())
|
||||
.or(p.thumb_path.as_deref())
|
||||
.unwrap_or("(no thumbnail)");
|
||||
let fav = if p.favorite { "*" } else { " " };
|
||||
println!("{fav} {} {} {thumb}", p.capture_time, p.uid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_albums() -> Result<()> {
|
||||
match control_request(CtlRequest::PhotoAlbums)? {
|
||||
CtlResponse::Albums {
|
||||
available: false, ..
|
||||
} => println!("This account has no photos volume."),
|
||||
CtlResponse::Albums { items, .. } if items.is_empty() => println!("No albums."),
|
||||
CtlResponse::Albums { items, .. } => {
|
||||
for album in items {
|
||||
let shared = if album.shared {
|
||||
" (shared with me)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
for p in items {
|
||||
let thumb = thumbs
|
||||
.get(&p.uid)
|
||||
.and_then(|p| p.as_deref())
|
||||
.or(p.thumb_path.as_deref())
|
||||
.unwrap_or("(no thumbnail)");
|
||||
println!("{} {} {thumb}", p.capture_time, p.uid);
|
||||
println!(
|
||||
"{:>6} {} {}{shared}",
|
||||
album.photo_count, album.uid, album.name
|
||||
);
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
@@ -1461,6 +1687,16 @@ fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_album(uid: String, limit: usize, offset: usize) -> Result<()> {
|
||||
match control_request(CtlRequest::AlbumPhotos { uid, offset, limit })? {
|
||||
CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."),
|
||||
CtlResponse::Photos { items, .. } => print_photo_page(items)?,
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_open_photo(uid: String) -> Result<()> {
|
||||
match control_request(CtlRequest::OpenPhoto { uid })? {
|
||||
CtlResponse::FilePath { path } => println!("{path}"),
|
||||
|
||||
@@ -21,4 +21,5 @@ sha2.workspace = true
|
||||
rusqlite.workspace = true
|
||||
keyring.workspace = true
|
||||
parking_lot.workspace = true
|
||||
async-trait.workspace = true
|
||||
libc.workspace = true
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Effective access carried by mounted Drive nodes.
|
||||
|
||||
use proton_drive_rs::MemberRole;
|
||||
|
||||
/// Effective write access for a node in a mounted tree.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Access {
|
||||
Owner,
|
||||
Editor,
|
||||
Viewer,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Access {
|
||||
pub fn writable(self) -> bool {
|
||||
matches!(self, Self::Owner | Self::Editor)
|
||||
}
|
||||
|
||||
pub(crate) fn as_db_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Owner => "owner",
|
||||
Self::Editor => "editor",
|
||||
Self::Viewer => "viewer",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_db_str(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"owner" => Some(Self::Owner),
|
||||
"editor" => Some(Self::Editor),
|
||||
"viewer" => Some(Self::Viewer),
|
||||
"unknown" => Some(Self::Unknown),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a share root's effective access from its exact membership role.
|
||||
///
|
||||
/// Owned trees fail open. A share whose role is absent or unrecognised fails
|
||||
/// closed. Descendants normally inherit directly at intern time; `Inherited`
|
||||
/// exists for the explicit wire role and therefore returns `parent`.
|
||||
pub fn access_for(role: Option<MemberRole>, parent: Access, under_share: bool) -> Access {
|
||||
match role {
|
||||
Some(MemberRole::Viewer) => Access::Viewer,
|
||||
Some(MemberRole::Editor | MemberRole::Admin) => Access::Editor,
|
||||
Some(MemberRole::Inherited) => parent,
|
||||
None if under_share => Access::Viewer,
|
||||
None => Access::Owner,
|
||||
}
|
||||
}
|
||||
|
||||
/// POSIX permission bits advertised for a mounted node.
|
||||
pub fn perm_bits(is_dir: bool, access: Access) -> u16 {
|
||||
match (is_dir, access.writable()) {
|
||||
(true, true) => 0o755,
|
||||
(false, true) => 0o644,
|
||||
(true, false) => 0o555,
|
||||
(false, false) => 0o444,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn access_for_covers_the_fail_open_and_fail_closed_table() {
|
||||
for parent in [
|
||||
Access::Owner,
|
||||
Access::Editor,
|
||||
Access::Viewer,
|
||||
Access::Unknown,
|
||||
] {
|
||||
assert_eq!(access_for(None, parent, false), Access::Owner);
|
||||
assert_eq!(access_for(None, parent, true), Access::Viewer);
|
||||
assert_eq!(
|
||||
access_for(Some(MemberRole::Viewer), parent, true),
|
||||
Access::Viewer
|
||||
);
|
||||
assert_eq!(
|
||||
access_for(Some(MemberRole::Editor), parent, true),
|
||||
Access::Editor
|
||||
);
|
||||
assert_eq!(
|
||||
access_for(Some(MemberRole::Admin), parent, true),
|
||||
Access::Editor
|
||||
);
|
||||
assert_eq!(
|
||||
access_for(Some(MemberRole::Inherited), parent, true),
|
||||
parent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_bits_follow_effective_access() {
|
||||
for writable in [Access::Owner, Access::Editor] {
|
||||
assert_eq!(perm_bits(true, writable), 0o755);
|
||||
assert_eq!(perm_bits(false, writable), 0o644);
|
||||
}
|
||||
for read_only in [Access::Viewer, Access::Unknown] {
|
||||
assert_eq!(perm_bits(true, read_only), 0o555);
|
||||
assert_eq!(perm_bits(false, read_only), 0o444);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,10 @@
|
||||
use keyring::Entry;
|
||||
use proton_drive_rs::{KeySalt, ProtonDriveClient};
|
||||
use proton_sdk::api::{HumanVerification, HumanVerificationCredential};
|
||||
use proton_sdk::cache::EncryptedCacheRepository;
|
||||
use proton_sdk::config::ProtonClientConfiguration;
|
||||
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
||||
use proton_sdk::telemetry::TracingTelemetry;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::{APP_VERSION, AppDirs, KEYRING_SERVICE, USER_AGENT};
|
||||
@@ -220,6 +222,21 @@ pub fn load() -> Result<StoredSession> {
|
||||
|
||||
/// Forget the persisted session (best-effort; absent entry is not an error).
|
||||
pub fn logout() -> Result<()> {
|
||||
// The persisted entity cache describes the account being logged out of. It
|
||||
// is encrypted under that account's mailbox password, but leaving it behind
|
||||
// would also mean the next account inherits a store it can only ever read as
|
||||
// misses. Best-effort: failing to remove a cache must not block the logout.
|
||||
if let Ok(dirs) = AppDirs::new() {
|
||||
let path = dirs.state_dir().join("sdk_cache.db");
|
||||
if let Some(cache) = crate::sdkcache::SdkCache::opened()
|
||||
&& let Err(e) = cache.clear_now()
|
||||
{
|
||||
tracing::warn!(error = %e, "clearing the SDK entity cache on logout failed");
|
||||
}
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
|
||||
}
|
||||
}
|
||||
match keyring_entry()?.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
@@ -259,10 +276,50 @@ pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
|
||||
|
||||
register_refresh_handler(&session, password.clone(), key_salts.clone());
|
||||
|
||||
let client = ProtonDriveClient::with_key_salts(&session, password.into_bytes(), key_salts);
|
||||
let client = tune(
|
||||
ProtonDriveClient::with_key_salts(&session, password.clone().into_bytes(), key_salts),
|
||||
password.as_bytes(),
|
||||
);
|
||||
Ok((client, session))
|
||||
}
|
||||
|
||||
/// The client settings every front-end wants, applied wherever a
|
||||
/// [`ProtonDriveClient`] is built for real work.
|
||||
///
|
||||
/// - **Telemetry** into `tracing`, so the SDK's own spans (transfers, block
|
||||
/// storage, per-request timings) land in the daemon's log next to the
|
||||
/// daemon's, instead of being dropped by the default no-op sink.
|
||||
/// - **Small-file uploads**: a file that fits in one block is uploaded as a
|
||||
/// single atomic request instead of the multi-step draft/block/commit dance.
|
||||
/// The SDK makes this opt-in because it has no remote feature-flag provider;
|
||||
/// the drain queue writes plenty of small files, and the shorter path is one
|
||||
/// failure point per write instead of three.
|
||||
/// - **A persistent entity cache** ([`crate::sdkcache`]), so a restart does not
|
||||
/// re-fetch and re-decrypt the tree the previous run already walked. It is
|
||||
/// wrapped in the SDK's [`EncryptedCacheRepository`] keyed by the mailbox
|
||||
/// password, so the decrypted node names it holds are not readable from the
|
||||
/// file alone — and a password change simply reads as a cold cache, since the
|
||||
/// SDK treats an undecryptable entry as a miss and clears the store.
|
||||
///
|
||||
/// Opening it is best-effort: a store this process cannot open costs cache
|
||||
/// hits, not the session, so the client falls back to the SDK's in-memory
|
||||
/// default.
|
||||
fn tune(client: ProtonDriveClient, mailbox_password: &[u8]) -> ProtonDriveClient {
|
||||
let client = client
|
||||
.with_telemetry(TracingTelemetry::shared())
|
||||
.with_small_file_upload(true);
|
||||
match AppDirs::new().and_then(|dirs| crate::sdkcache::SdkCache::shared(&dirs)) {
|
||||
Ok(cache) => client.with_entity_repository(EncryptedCacheRepository::shared(
|
||||
cache,
|
||||
mailbox_password.to_vec(),
|
||||
)),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "persistent SDK entity cache unavailable; using memory");
|
||||
client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the session's current tokens back to the keyring.
|
||||
///
|
||||
/// The session does not carry the mailbox password (it is only needed to rebuild
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Per-node outcomes of the SDK's batch node operations.
|
||||
//!
|
||||
//! `trash_nodes`, `restore_nodes`, `delete_nodes` and `move_nodes` report one
|
||||
//! outcome per node instead of failing the whole call: a node the server
|
||||
//! rejects leaves the rest of the batch applied. The daemon's callers fall into
|
||||
//! two shapes, and both are here so neither has to re-derive the fold:
|
||||
//!
|
||||
//! - one node in, one answer out — [`into_unit`], which collapses the single
|
||||
//! outcome back to a `Result`;
|
||||
//! - a user-supplied list — [`split`], which keeps the successes and hands back
|
||||
//! the failures to report.
|
||||
|
||||
use proton_sdk::error::ProtonError;
|
||||
use proton_sdk::ids::NodeUid;
|
||||
|
||||
/// One node's outcome, as the SDK batch operations report it.
|
||||
pub type Outcome = (NodeUid, Result<(), ProtonError>);
|
||||
|
||||
/// Collapse a batch result to the first per-node failure, for the callers that
|
||||
/// pass exactly one node and want the old all-or-nothing answer.
|
||||
///
|
||||
/// An empty outcome list is `Ok`: the server accepted the request and reported
|
||||
/// on nothing, which is not an error the caller can act on.
|
||||
pub fn into_unit(outcomes: Vec<Outcome>) -> Result<(), ProtonError> {
|
||||
for (_, outcome) in outcomes {
|
||||
outcome?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Split a batch result into the nodes that succeeded and the ones that failed.
|
||||
pub fn split(outcomes: Vec<Outcome>) -> (Vec<NodeUid>, Vec<(NodeUid, ProtonError)>) {
|
||||
let mut done = Vec::with_capacity(outcomes.len());
|
||||
let mut failed = Vec::new();
|
||||
for (uid, outcome) in outcomes {
|
||||
match outcome {
|
||||
Ok(()) => done.push(uid),
|
||||
Err(e) => failed.push((uid, e)),
|
||||
}
|
||||
}
|
||||
(done, failed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proton_sdk::ids::{LinkId, VolumeId};
|
||||
|
||||
fn uid(link: &str) -> NodeUid {
|
||||
NodeUid::new(VolumeId::new("vol"), LinkId::new(link))
|
||||
}
|
||||
|
||||
fn failure() -> ProtonError {
|
||||
ProtonError::invalid_operation("nope")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_unit_is_ok_when_every_node_succeeded() {
|
||||
assert!(into_unit(vec![(uid("a"), Ok(())), (uid("b"), Ok(()))]).is_ok());
|
||||
assert!(
|
||||
into_unit(Vec::new()).is_ok(),
|
||||
"nothing reported is not a failure"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_unit_surfaces_a_failed_node() {
|
||||
let outcomes = vec![(uid("a"), Ok(())), (uid("b"), Err(failure()))];
|
||||
assert!(into_unit(outcomes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_keeps_successes_and_failures_apart() {
|
||||
let outcomes = vec![
|
||||
(uid("a"), Ok(())),
|
||||
(uid("b"), Err(failure())),
|
||||
(uid("c"), Ok(())),
|
||||
];
|
||||
let (done, failed) = split(outcomes);
|
||||
assert_eq!(done, vec![uid("a"), uid("c")]);
|
||||
assert_eq!(failed.len(), 1);
|
||||
assert_eq!(failed[0].0, uid("b"));
|
||||
}
|
||||
}
|
||||
+279
-11
@@ -17,6 +17,7 @@
|
||||
//! [`Node`]: proton_drive_rs::Node
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::fmt;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
@@ -28,10 +29,11 @@ use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::db::{CacheEntryInput, Db};
|
||||
use crate::error::Result;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// `cache_entries.kind` tag for whole-file content blobs.
|
||||
const KIND_BLOB: &str = "blob";
|
||||
static STAGING_N: AtomicU64 = AtomicU64::new(0);
|
||||
/// `cache_entries.kind` tag for on-demand block-cache chunks.
|
||||
const KIND_BLOCK: &str = "block";
|
||||
/// `cache_entries.kind` tag for cached thumbnails.
|
||||
@@ -169,6 +171,35 @@ pub struct Baseline {
|
||||
pub revision_id: Option<String>,
|
||||
}
|
||||
|
||||
/// A denied-write rescue that retained the bytes but could not fully publish
|
||||
/// their recovery metadata.
|
||||
#[derive(Debug)]
|
||||
pub struct PreserveWriteError {
|
||||
/// The actual path that still contains the accepted bytes.
|
||||
pub surviving_path: PathBuf,
|
||||
/// The recovery metadata that still describes those bytes, when one could
|
||||
/// be made durable. Callers must never clear this after an error.
|
||||
pub marker_path: Option<PathBuf>,
|
||||
error: Error,
|
||||
}
|
||||
|
||||
impl fmt::Display for PreserveWriteError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
formatter,
|
||||
"{} (bytes retained at {})",
|
||||
self.error,
|
||||
self.surviving_path.display()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PreserveWriteError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
Some(&self.error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Content cache rooted at a directory, with a sibling pin-registry file.
|
||||
pub struct ContentCache {
|
||||
/// Directory holding `<key>` blobs and `<key>.meta` tags.
|
||||
@@ -225,6 +256,18 @@ pub struct ContentCache {
|
||||
}
|
||||
|
||||
impl ContentCache {
|
||||
fn staging_path(&self, meta: &StagedWrite) -> PathBuf {
|
||||
// The uid goes in the name so a staged file can be tied back to its node
|
||||
// without a database; `/` in a uid would otherwise open a path.
|
||||
let safe_uid = meta.uid.replace(['/', '\\'], "_");
|
||||
self.staging_dir.join(format!(
|
||||
"{}-{}-{}",
|
||||
safe_uid,
|
||||
now_millis(),
|
||||
STAGING_N.fetch_add(1, Ordering::Relaxed)
|
||||
))
|
||||
}
|
||||
|
||||
/// Open (and create) a cache under `content_dir`, with the pin registry at
|
||||
/// `pins_path` and a `max_bytes` size cap (`0` = unlimited). Both parent
|
||||
/// directories are created if missing. `db` is the shared metadata database
|
||||
@@ -910,16 +953,7 @@ impl ContentCache {
|
||||
pub fn stage_write(&self, meta: &StagedWrite, scratch: &Path) -> Result<PathBuf> {
|
||||
use std::io::Write as _;
|
||||
|
||||
static N: AtomicU64 = AtomicU64::new(0);
|
||||
// The uid goes in the name so a staged file can be tied back to its node
|
||||
// without a database; `/` in a uid would otherwise open a path.
|
||||
let safe_uid = meta.uid.replace(['/', '\\'], "_");
|
||||
let path = self.staging_dir.join(format!(
|
||||
"{}-{}-{}",
|
||||
safe_uid,
|
||||
now_millis(),
|
||||
N.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let path = self.staging_path(meta);
|
||||
let copied = if let Err(e) = std::fs::rename(scratch, &path) {
|
||||
if e.kind() != std::io::ErrorKind::CrossesDevices {
|
||||
return Err(e.into());
|
||||
@@ -949,6 +983,154 @@ impl ContentCache {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Preserve accepted bytes which cannot be queued.
|
||||
///
|
||||
/// Scratch and staging normally share a filesystem, so the normal path is
|
||||
/// an atomic rename. If that is impossible, the source first receives a
|
||||
/// durable recovery marker and remains in place until a copied destination,
|
||||
/// its sidecar, and the staging directory are all synced.
|
||||
pub fn preserve_write(
|
||||
&self,
|
||||
meta: &StagedWrite,
|
||||
scratch: &Path,
|
||||
) -> std::result::Result<PathBuf, PreserveWriteError> {
|
||||
let path = self.staging_path(meta);
|
||||
self.preserve_write_at(meta, scratch, &path)
|
||||
}
|
||||
|
||||
fn preserve_write_at(
|
||||
&self,
|
||||
meta: &StagedWrite,
|
||||
scratch: &Path,
|
||||
path: &Path,
|
||||
) -> std::result::Result<PathBuf, PreserveWriteError> {
|
||||
use std::io::Write as _;
|
||||
|
||||
let retained = |surviving_path: &Path, error: Error| {
|
||||
let adjacent = Self::scratch_sidecar(surviving_path);
|
||||
let source = Self::scratch_sidecar(scratch);
|
||||
let marker_path = [adjacent, source].into_iter().find(|path| path.is_file());
|
||||
PreserveWriteError {
|
||||
surviving_path: surviving_path.to_path_buf(),
|
||||
marker_path,
|
||||
error,
|
||||
}
|
||||
};
|
||||
// If rename cannot happen, this marker is what lets the next open move
|
||||
// the still-authoritative source into recovery instead of deleting it.
|
||||
let marker_result = self.mark_scratch_durable(scratch, meta);
|
||||
let side = path.with_extension("json");
|
||||
let side_tmp = path.with_extension("json.tmp");
|
||||
let publish_sidecar = || -> Result<()> {
|
||||
let mut side_file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&side_tmp)?;
|
||||
side_file.write_all(&serde_json::to_vec_pretty(meta)?)?;
|
||||
side_file.sync_all()?;
|
||||
drop(side_file);
|
||||
std::fs::rename(&side_tmp, &side)?;
|
||||
std::fs::File::open(&self.staging_dir)?.sync_all()?;
|
||||
Ok(())
|
||||
};
|
||||
|
||||
match std::fs::rename(scratch, path) {
|
||||
Ok(()) => {
|
||||
if let Err(error) = std::fs::File::open(path).and_then(|file| file.sync_all()) {
|
||||
if std::fs::rename(path, scratch).is_ok() {
|
||||
if !Self::scratch_sidecar(scratch).is_file() {
|
||||
let _ = self.mark_scratch_durable(scratch, meta);
|
||||
}
|
||||
let _ =
|
||||
std::fs::File::open(&self.scratch_dir).and_then(|dir| dir.sync_all());
|
||||
return Err(retained(scratch, error.into()));
|
||||
}
|
||||
return Err(retained(path, error.into()));
|
||||
}
|
||||
let scratch_side = Self::scratch_sidecar(scratch);
|
||||
let side_result = if marker_result.is_ok() {
|
||||
std::fs::rename(&scratch_side, &side)
|
||||
.map_err(Error::from)
|
||||
.and_then(|()| {
|
||||
std::fs::File::open(&self.staging_dir)?
|
||||
.sync_all()
|
||||
.map_err(Error::from)
|
||||
})
|
||||
} else {
|
||||
publish_sidecar()
|
||||
};
|
||||
if let Err(error) = side_result {
|
||||
let _ = std::fs::remove_file(&side_tmp);
|
||||
// Publication did not complete. Put the blob back beside
|
||||
// the source marker so startup recovery still has a
|
||||
// reconstructable pair, rather than an unexplained staging
|
||||
// blob and a marker whose source vanished.
|
||||
if std::fs::rename(path, scratch).is_ok() {
|
||||
if !Self::scratch_sidecar(scratch).is_file() {
|
||||
let _ = self.mark_scratch_durable(scratch, meta);
|
||||
}
|
||||
let _ =
|
||||
std::fs::File::open(&self.scratch_dir).and_then(|dir| dir.sync_all());
|
||||
return Err(retained(scratch, error));
|
||||
}
|
||||
return Err(retained(path, error));
|
||||
}
|
||||
Ok(path.to_path_buf())
|
||||
}
|
||||
Err(rename_error) => {
|
||||
let source_marked =
|
||||
marker_result.is_ok() || Self::scratch_sidecar(scratch).exists();
|
||||
if let Err(marker_error) = marker_result {
|
||||
tracing::warn!(
|
||||
error = %marker_error,
|
||||
path = %scratch.display(),
|
||||
"could not mark rescue source durable before copy fallback"
|
||||
);
|
||||
}
|
||||
if let Err(error) = std::fs::copy(scratch, path) {
|
||||
if !source_marked {
|
||||
let recovery = self.recovery_dir.join(
|
||||
path.file_name()
|
||||
.unwrap_or_else(|| std::ffi::OsStr::new("denied-write")),
|
||||
);
|
||||
if std::fs::rename(scratch, &recovery).is_ok() {
|
||||
let _ = std::fs::File::open(&self.recovery_dir)
|
||||
.and_then(|dir| dir.sync_all());
|
||||
return Err(retained(
|
||||
&recovery,
|
||||
Error::Other(format!(
|
||||
"rename failed ({rename_error}); copy fallback failed ({error})"
|
||||
)),
|
||||
));
|
||||
}
|
||||
}
|
||||
return Err(retained(
|
||||
scratch,
|
||||
Error::Other(format!(
|
||||
"rename failed ({rename_error}); copy fallback failed ({error})"
|
||||
)),
|
||||
));
|
||||
}
|
||||
if let Err(error) = std::fs::File::open(path).and_then(|file| file.sync_all()) {
|
||||
let surviving = if source_marked { scratch } else { path };
|
||||
return Err(retained(surviving, error.into()));
|
||||
}
|
||||
if let Err(error) = publish_sidecar() {
|
||||
let _ = std::fs::remove_file(&side_tmp);
|
||||
let surviving = if source_marked { scratch } else { path };
|
||||
return Err(retained(surviving, error));
|
||||
}
|
||||
// Deliberately last: fallback never removes its marked source
|
||||
// until destination publication is complete.
|
||||
if let Err(error) = std::fs::remove_file(scratch) {
|
||||
return Err(retained(path, error.into()));
|
||||
}
|
||||
self.clear_scratch_durable(scratch);
|
||||
Ok(path.to_path_buf())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a staged blob and its sidecar, once its bytes are safely on the
|
||||
/// remote (or the op that owned them has been superseded). Best effort:
|
||||
/// leftovers cost disk, while failing here would strand a drained op.
|
||||
@@ -1906,6 +2088,92 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn preservation_meta() -> StagedWrite {
|
||||
StagedWrite {
|
||||
uid: uid("preserved").to_string(),
|
||||
len: 14,
|
||||
base_size: 0,
|
||||
base_mtime: 100,
|
||||
authored: vec![(0, 14)],
|
||||
complete: true,
|
||||
based_on: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphan_preservation_publishes_exact_bytes_then_removes_source() {
|
||||
use std::io::Write as _;
|
||||
|
||||
let (c, _dir) = cache();
|
||||
let (mut file, scratch) = c.create_scratch().unwrap();
|
||||
file.write_all(b"accepted bytes").unwrap();
|
||||
file.sync_all().unwrap();
|
||||
drop(file);
|
||||
|
||||
let preserved = c.preserve_write(&preservation_meta(), &scratch).unwrap();
|
||||
|
||||
assert!(!scratch.exists());
|
||||
assert_eq!(std::fs::read(&preserved).unwrap(), b"accepted bytes");
|
||||
let sidecar: StagedWrite =
|
||||
serde_json::from_slice(&std::fs::read(preserved.with_extension("json")).unwrap())
|
||||
.unwrap();
|
||||
let expected = preservation_meta();
|
||||
assert_eq!(sidecar.uid, expected.uid);
|
||||
assert_eq!(sidecar.len, expected.len);
|
||||
assert_eq!(sidecar.authored, expected.authored);
|
||||
assert_eq!(sidecar.complete, expected.complete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphan_preservation_sidecar_failure_reports_surviving_blob_across_reopen() {
|
||||
use std::io::Write as _;
|
||||
|
||||
let (c, dir) = cache();
|
||||
let (mut file, scratch) = c.create_scratch().unwrap();
|
||||
file.write_all(b"only surviving copy").unwrap();
|
||||
file.sync_all().unwrap();
|
||||
drop(file);
|
||||
|
||||
let destination = c.staging_dir.join("forced-preservation-failure");
|
||||
let side = destination.with_extension("json");
|
||||
std::fs::create_dir(&side).unwrap();
|
||||
|
||||
let error = c
|
||||
.preserve_write_at(&preservation_meta(), &scratch, &destination)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.surviving_path, scratch);
|
||||
assert!(
|
||||
scratch.exists(),
|
||||
"failed sidecar publication must roll the blob back beside its marker"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(&error.surviving_path).unwrap(),
|
||||
b"only surviving copy"
|
||||
);
|
||||
assert_eq!(
|
||||
error.marker_path,
|
||||
Some(ContentCache::scratch_sidecar(&scratch))
|
||||
);
|
||||
assert!(
|
||||
side.is_dir(),
|
||||
"the induced sidecar failure remains observable"
|
||||
);
|
||||
|
||||
let content = dir.path().join("content");
|
||||
let pins = dir.path().join("pins-reopen.json");
|
||||
drop(c);
|
||||
let reopened =
|
||||
ContentCache::open(content, pins, 0, Arc::new(Db::open_in_memory().unwrap())).unwrap();
|
||||
let recovered = reopened.recovered_writes();
|
||||
assert_eq!(recovered.len(), 1);
|
||||
assert_eq!(
|
||||
std::fs::read(&recovered[0].0).unwrap(),
|
||||
b"only surviving copy"
|
||||
);
|
||||
assert_eq!(recovered[0].1.uid, preservation_meta().uid);
|
||||
drop(reopened);
|
||||
}
|
||||
|
||||
/// A2: `fsync(2)` promises the bytes survive a crash, but queueing happens
|
||||
/// at `release`. A scratch file marked durable must therefore outlive the
|
||||
/// open that clears the scratch directory — and an unmarked one must not,
|
||||
|
||||
@@ -14,6 +14,7 @@ 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`
|
||||
@@ -62,6 +63,10 @@ pub enum Request {
|
||||
/// timeline. Like `kind`, the offset is relative to the filtered set.
|
||||
#[serde(default)]
|
||||
range: Option<(i64, i64)>,
|
||||
/// Restrict the page to favourited photos. Older front-ends omit it and
|
||||
/// get the whole timeline, as before.
|
||||
#[serde(default)]
|
||||
favorites: bool,
|
||||
},
|
||||
/// The months the timeline spans (newest first, with per-month counts) so a
|
||||
/// front-end can build a date scrubber without paging the whole library.
|
||||
@@ -70,12 +75,34 @@ pub enum Request {
|
||||
#[serde(default)]
|
||||
kind: Option<PhotoKind>,
|
||||
},
|
||||
/// 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,
|
||||
},
|
||||
/// 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> },
|
||||
/// Download a photo's full content into the cache; replies with its path.
|
||||
OpenPhoto { uid: String },
|
||||
/// Add or remove Proton's `Favorite` tag on a photo. Replies with
|
||||
/// [`Response::Ok`].
|
||||
///
|
||||
/// Favouriting a photo that is *not* on this account's own photos volume —
|
||||
/// one shared with us, or one that lives only in an album — needs it
|
||||
/// re-encrypted for our timeline, which the SDK does not implement yet; the
|
||||
/// daemon reports that as an error rather than silently doing nothing.
|
||||
SetPhotoFavorite { uid: String, favorite: bool },
|
||||
/// Upload the photo at `source_path` under the given name and media type.
|
||||
///
|
||||
/// A path, not the bytes: this protocol is line-delimited JSON, and
|
||||
@@ -194,6 +221,11 @@ pub enum Request {
|
||||
/// 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,
|
||||
@@ -240,9 +272,20 @@ pub enum Request {
|
||||
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`].
|
||||
@@ -252,6 +295,13 @@ pub enum Request {
|
||||
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 {
|
||||
@@ -259,6 +309,12 @@ pub enum Request {
|
||||
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).
|
||||
@@ -268,9 +324,51 @@ pub enum Request {
|
||||
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 },
|
||||
|
||||
// ---- 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
|
||||
@@ -555,6 +653,36 @@ pub struct SharedItem {
|
||||
pub link: Option<PublicLinkInfo>,
|
||||
}
|
||||
|
||||
/// One entry of a file's version history (in [`Response::Revisions`]).
|
||||
///
|
||||
/// The size and modification time are the ones the *uploader* claimed in the
|
||||
/// revision's extended attributes, so an old client that wrote none leaves them
|
||||
/// `None`; `size_on_storage` is always known but counts ciphertext, which is
|
||||
/// larger than the file. A front-end shows the claimed size when it has one and
|
||||
/// falls back to the storage size.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct RevisionInfo {
|
||||
/// Server-assigned id — the handle for [`Request::RestoreRevision`],
|
||||
/// [`Request::DeleteRevision`] and [`Request::SaveRevisionAs`].
|
||||
pub id: String,
|
||||
/// True for the revision that is the file's current content. It cannot be
|
||||
/// deleted, and restoring it is a no-op.
|
||||
pub is_active: bool,
|
||||
/// When the revision was created, epoch seconds.
|
||||
pub created: i64,
|
||||
/// Encrypted size on Proton's storage, in bytes.
|
||||
pub size_on_storage: i64,
|
||||
/// Plaintext size as claimed by the uploader, when it wrote one.
|
||||
pub claimed_size: Option<i64>,
|
||||
/// Modification time as claimed by the uploader (ISO-8601, verbatim).
|
||||
pub claimed_modified: Option<String>,
|
||||
/// The address that signed the revision, when it was not signed anonymously
|
||||
/// by the node key.
|
||||
pub signed_by: Option<String>,
|
||||
/// Whether the revision carries thumbnails.
|
||||
pub has_thumbnails: bool,
|
||||
}
|
||||
|
||||
/// What happened, in an [`ActivityEntry`]. Kept coarse: a front-end maps each to
|
||||
/// an icon and a verb, and the human detail lives in the entry's fields.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -667,6 +795,12 @@ pub struct DirEntry {
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
|
||||
@@ -907,6 +1041,29 @@ pub struct PhotoMonth {
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
/// 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 photo in a [`Request::PhotosTimeline`] page.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct PhotoItem {
|
||||
@@ -932,6 +1089,11 @@ pub struct PhotoItem {
|
||||
/// the split omit it; a front-end then treats everything as a still photo.
|
||||
#[serde(default = "default_photo_kind")]
|
||||
pub kind: PhotoKind,
|
||||
/// Whether the photo is favourited. Older daemons omit it, which a front-end
|
||||
/// reads as "not favourited" — the same thing it would show for a photo whose
|
||||
/// tags it has not learned yet.
|
||||
#[serde(default)]
|
||||
pub favorite: bool,
|
||||
}
|
||||
|
||||
/// A daemon too old to classify a timeline entry is assumed to have served a
|
||||
@@ -1062,6 +1224,13 @@ pub enum Response {
|
||||
#[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>,
|
||||
},
|
||||
/// The months the timeline spans (reply to [`Request::PhotoMonths`]),
|
||||
/// newest first.
|
||||
PhotoMonths { months: Vec<PhotoMonth> },
|
||||
@@ -1094,6 +1263,8 @@ pub enum Response {
|
||||
},
|
||||
/// 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> },
|
||||
/// Folders offered for restore (reply to [`Request::ListRestorableFolders`]).
|
||||
@@ -1118,6 +1289,9 @@ pub enum Response {
|
||||
AccountQuota { max_space: i64, used_space: i64 },
|
||||
/// Nodes I have shared with others (reply to [`Request::ListSharedByMe`]).
|
||||
SharedByMe { items: Vec<SharedItem> },
|
||||
/// A file's version history, newest first (reply to
|
||||
/// [`Request::ListRevisions`]).
|
||||
Revisions { items: Vec<RevisionInfo> },
|
||||
/// The daemon's recent activity, newest first (reply to
|
||||
/// [`Request::ListActivity`]).
|
||||
Activity { items: Vec<ActivityEntry> },
|
||||
@@ -1541,6 +1715,7 @@ mod tests {
|
||||
#[test]
|
||||
fn sharing_requests_roundtrip() {
|
||||
let reqs = [
|
||||
Request::ListLocations,
|
||||
Request::ListDevices,
|
||||
Request::RenameDevice {
|
||||
uid: "dev-1".into(),
|
||||
@@ -1580,28 +1755,58 @@ mod tests {
|
||||
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,
|
||||
@@ -1635,6 +1840,418 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
},
|
||||
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,
|
||||
},
|
||||
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(),
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
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"}}"#,
|
||||
),
|
||||
];
|
||||
for (request, wire) in cases {
|
||||
assert_eq!(serde_json::to_string(&request).unwrap(), wire);
|
||||
let decoded: Request = serde_json::from_str(wire).unwrap();
|
||||
assert_eq!(serde_json::to_string(&decoded).unwrap(), wire);
|
||||
}
|
||||
}
|
||||
|
||||
/// A front-end that predates the favourites filter sends no `favorites`
|
||||
/// field, and must keep getting the whole timeline rather than an empty page.
|
||||
#[test]
|
||||
fn timeline_requests_without_the_favorites_filter_still_decode() {
|
||||
let decoded: Request =
|
||||
serde_json::from_str(r#"{"PhotosTimeline":{"offset":0,"limit":50}}"#).unwrap();
|
||||
assert!(matches!(
|
||||
decoded,
|
||||
Request::PhotosTimeline {
|
||||
favorites: false,
|
||||
kind: None,
|
||||
range: None,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
let wire = serde_json::to_string(&Request::SetPhotoFavorite {
|
||||
uid: "vol~link".into(),
|
||||
favorite: true,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
wire,
|
||||
r#"{"SetPhotoFavorite":{"uid":"vol~link","favorite":true}}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_share_requests_keep_their_existing_wire_shape() {
|
||||
let cases = [
|
||||
(
|
||||
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",
|
||||
),
|
||||
];
|
||||
|
||||
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, .. } => 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 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]
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//! Photo albums: the album listing and each album's contents, persisted like
|
||||
//! the timeline so the Albums view opens from disk instead of re-enumerating the
|
||||
//! photos volume on every launch.
|
||||
//!
|
||||
//! An album's photos are stored per album rather than as a join against
|
||||
//! `photos`: an album shared with us lives on the sharer's volume, so its photos
|
||||
//! never appear in our own timeline and have nowhere else to record what a
|
||||
//! thumbnail attempt learned about them.
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{Db, StoredPhoto, THUMB_UNKNOWN};
|
||||
use crate::Result;
|
||||
|
||||
/// One album of the persisted listing, ordered newest-activity-first in `seq`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StoredAlbum {
|
||||
pub uid: String,
|
||||
pub name: String,
|
||||
pub photo_count: usize,
|
||||
/// The photo shown as the album's cover, when the server named one.
|
||||
pub cover_uid: Option<String>,
|
||||
/// Epoch seconds of the last change to the album's contents.
|
||||
pub last_activity: Option<i64>,
|
||||
/// True when the album lives on someone else's photos volume.
|
||||
pub shared: bool,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Replace the album listing wholesale, in the given order.
|
||||
pub fn albums_replace(&self, albums: &[StoredAlbum]) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
// Contents of an album that no longer exists (unshared, deleted) go with
|
||||
// it — otherwise they would linger forever, keyed by an album uid no
|
||||
// listing mentions again.
|
||||
{
|
||||
let kept: std::collections::HashSet<&str> =
|
||||
albums.iter().map(|a| a.uid.as_str()).collect();
|
||||
let stale: Vec<String> = {
|
||||
let mut stmt = tx.prepare("SELECT DISTINCT album_uid FROM album_photos")?;
|
||||
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||
rows.filter_map(|r| r.ok())
|
||||
.filter(|uid| !kept.contains(uid.as_str()))
|
||||
.collect()
|
||||
};
|
||||
let mut stmt = tx.prepare("DELETE FROM album_photos WHERE album_uid = ?1")?;
|
||||
for uid in stale {
|
||||
stmt.execute([uid])?;
|
||||
}
|
||||
}
|
||||
tx.execute("DELETE FROM albums", [])?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO albums
|
||||
(uid, name, photo_count, cover_uid, last_activity, shared, seq)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
)?;
|
||||
for (seq, album) in albums.iter().enumerate() {
|
||||
stmt.execute(params![
|
||||
album.uid,
|
||||
album.name,
|
||||
album.photo_count as i64,
|
||||
album.cover_uid,
|
||||
album.last_activity,
|
||||
album.shared as i64,
|
||||
seq as i64,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The persisted album listing, in stored order.
|
||||
pub fn albums_list(&self) -> Result<Vec<StoredAlbum>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT uid, name, photo_count, cover_uid, last_activity, shared
|
||||
FROM albums ORDER BY seq",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
Ok(StoredAlbum {
|
||||
uid: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
photo_count: r.get::<_, i64>(2)?.max(0) as usize,
|
||||
cover_uid: r.get(3)?,
|
||||
last_activity: r.get(4)?,
|
||||
shared: r.get::<_, i64>(5)? != 0,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Number of albums in the persisted listing.
|
||||
pub fn albums_count(&self) -> Result<usize> {
|
||||
let conn = self.conn.lock();
|
||||
let n: i64 = conn.query_row("SELECT COUNT(*) FROM albums", [], |r| r.get(0))?;
|
||||
Ok(n.max(0) as usize)
|
||||
}
|
||||
|
||||
/// Replace one album's contents, in the given (newest-capture-first) order.
|
||||
/// Learned aspect ratios and thumbnail verdicts survive the replacement, the
|
||||
/// same way [`Db::photos_replace`] keeps them for the timeline.
|
||||
pub fn album_photos_replace(
|
||||
&self,
|
||||
album_uid: &str,
|
||||
items: &[(String, i64, Option<String>, Option<String>)],
|
||||
) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let learned: HashMap<String, (Option<f64>, i64, Option<String>)> = {
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT uid, ratio, thumb_state, media_type FROM album_photos
|
||||
WHERE album_uid = ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map([album_uid], |r| {
|
||||
Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?, r.get(3)?)))
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
|
||||
tx.execute("DELETE FROM album_photos WHERE album_uid = ?1", [album_uid])?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO album_photos
|
||||
(album_uid, uid, capture_time, name, media_type, kind, ratio, thumb_state, seq)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
)?;
|
||||
for (seq, (uid, capture_time, name, media_type)) in items.iter().enumerate() {
|
||||
let (ratio, thumb_state, learned_media) =
|
||||
learned
|
||||
.get(uid)
|
||||
.cloned()
|
||||
.unwrap_or((None, THUMB_UNKNOWN, None));
|
||||
let media_type = media_type.clone().or(learned_media);
|
||||
let kind =
|
||||
crate::control::PhotoKind::classify(name.as_deref(), media_type.as_deref());
|
||||
stmt.execute(params![
|
||||
album_uid,
|
||||
uid,
|
||||
capture_time,
|
||||
name,
|
||||
media_type,
|
||||
kind.as_i64(),
|
||||
ratio,
|
||||
thumb_state,
|
||||
seq as i64,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One page of an album's photos, in stored order.
|
||||
pub fn album_photos_page(
|
||||
&self,
|
||||
album_uid: &str,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
|
||||
WHERE album_uid = ?1 ORDER BY seq LIMIT ?2 OFFSET ?3",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![album_uid, limit as i64, offset as i64], |r| {
|
||||
Ok(StoredPhoto {
|
||||
uid: r.get(0)?,
|
||||
capture_time: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||
// Album rows have no favourite state of their own: an album
|
||||
// photo on someone else's volume is not in our timeline, and
|
||||
// favouriting one is not supported yet anyway.
|
||||
favorite: false,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// How many photos of `album_uid` are persisted.
|
||||
pub fn album_photos_count(&self, album_uid: &str) -> Result<usize> {
|
||||
let conn = self.conn.lock();
|
||||
let n: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM album_photos WHERE album_uid = ?1",
|
||||
[album_uid],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(n.max(0) as usize)
|
||||
}
|
||||
|
||||
/// The album-held photos among `uids`, one row per uid (a photo in two albums
|
||||
/// is returned once). Backs the thumbnail path for photos that are in an
|
||||
/// album but not in our own timeline — everything in a shared album, and an
|
||||
/// album cover whose photo the current timeline page hasn't reached.
|
||||
pub fn album_photos_by_uid(&self, uids: &[String]) -> Result<Vec<StoredPhoto>> {
|
||||
if uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.conn.lock();
|
||||
let placeholders = vec!["?"; uids.len()].join(",");
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
|
||||
WHERE uid IN ({placeholders}) GROUP BY uid"
|
||||
))?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(uids), |r| {
|
||||
Ok(StoredPhoto {
|
||||
uid: r.get(0)?,
|
||||
capture_time: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||
// Album rows have no favourite state of their own: an album
|
||||
// photo on someone else's volume is not in our timeline, and
|
||||
// favouriting one is not supported yet anyway.
|
||||
favorite: false,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Record a thumbnail verdict against every album row for `uid`, so what one
|
||||
/// attempt learned is not re-learned per album the photo sits in. The
|
||||
/// timeline's own copy is updated separately by [`Db::photo_set_thumb`].
|
||||
pub fn album_photo_set_thumb(&self, uid: &str, state: i64, ratio: Option<f64>) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE album_photos SET thumb_state = ?2, ratio = COALESCE(?3, ratio) WHERE uid = ?1",
|
||||
params![uid, state, ratio],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -77,10 +77,12 @@ const REPORTED_TABLES: &[&str] = &[
|
||||
"trash",
|
||||
"device",
|
||||
"sync_folder",
|
||||
"mount",
|
||||
"sync_entry",
|
||||
"pending_op",
|
||||
"activity",
|
||||
"local_files",
|
||||
"share_access",
|
||||
];
|
||||
|
||||
impl Db {
|
||||
|
||||
@@ -9,7 +9,7 @@ use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
/// Current schema version. Bump on every forward migration added below.
|
||||
pub(super) const SCHEMA_VERSION: i64 = 16;
|
||||
pub(super) const SCHEMA_VERSION: i64 = 20;
|
||||
|
||||
impl Db {
|
||||
pub(super) fn migrate(&self) -> Result<()> {
|
||||
@@ -89,6 +89,18 @@ impl Db {
|
||||
if current < 16 {
|
||||
tx.execute_batch(MIGRATION_V16)?;
|
||||
}
|
||||
if current < 17 {
|
||||
tx.execute_batch(MIGRATION_V17)?;
|
||||
}
|
||||
if current < 18 {
|
||||
tx.execute_batch(MIGRATION_V18)?;
|
||||
}
|
||||
if current < 19 {
|
||||
tx.execute_batch(MIGRATION_V19)?;
|
||||
}
|
||||
if current < 20 {
|
||||
tx.execute_batch(MIGRATION_V20)?;
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
@@ -411,3 +423,107 @@ CREATE INDEX IF NOT EXISTS idx_local_files_name_nocase
|
||||
ON local_files(name COLLATE NOCASE);
|
||||
INSERT INTO local_fts(local_fts) VALUES('rebuild');
|
||||
";
|
||||
|
||||
/// Schema v17: effective access for roots shared with this account. The role is
|
||||
/// persisted separately from `nodes.node_json` so a cold/offline mount can
|
||||
/// enforce the last known permission even when the SDK cannot refresh metadata.
|
||||
const MIGRATION_V17: &str = "
|
||||
CREATE TABLE share_access (
|
||||
root_uid TEXT PRIMARY KEY,
|
||||
access TEXT NOT NULL
|
||||
CHECK (access IN ('owner', 'editor', 'viewer', 'unknown'))
|
||||
);
|
||||
";
|
||||
|
||||
/// Schema v18: unified location presentation. Device rows carry only their
|
||||
/// `sync_folder` identity; reads join all device state from that authoritative
|
||||
/// table. The trigger projects future folders, while the foreign key removes a
|
||||
/// presentation row with its sync folder.
|
||||
const MIGRATION_V18: &str = "
|
||||
CREATE TABLE mount (
|
||||
id INTEGER PRIMARY KEY,
|
||||
kind TEXT NOT NULL
|
||||
CHECK (kind IN ('myfiles', 'device', 'shared')),
|
||||
sync_folder_id INTEGER REFERENCES sync_folder(id) ON DELETE CASCADE,
|
||||
share_root_uid TEXT,
|
||||
local_path TEXT NOT NULL DEFAULT '',
|
||||
root_uid TEXT NOT NULL DEFAULT '',
|
||||
root_share_id TEXT NOT NULL DEFAULT '',
|
||||
mode TEXT NOT NULL DEFAULT 'unknown',
|
||||
access TEXT NOT NULL DEFAULT 'rw'
|
||||
CHECK (access IN ('rw', 'ro')),
|
||||
CHECK (
|
||||
(kind = 'myfiles' AND sync_folder_id IS NULL AND share_root_uid IS NULL)
|
||||
OR
|
||||
(kind = 'device' AND sync_folder_id IS NOT NULL AND share_root_uid IS NULL)
|
||||
OR
|
||||
(kind = 'shared' AND sync_folder_id IS NULL AND share_root_uid IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX mount_myfiles
|
||||
ON mount(kind) WHERE kind = 'myfiles';
|
||||
CREATE UNIQUE INDEX mount_device
|
||||
ON mount(sync_folder_id) WHERE kind = 'device';
|
||||
CREATE UNIQUE INDEX mount_shared
|
||||
ON mount(share_root_uid) WHERE kind = 'shared';
|
||||
|
||||
INSERT INTO mount (kind, sync_folder_id)
|
||||
SELECT 'device', id FROM sync_folder;
|
||||
|
||||
CREATE TRIGGER mount_sync_folder_insert
|
||||
AFTER INSERT ON sync_folder
|
||||
BEGIN
|
||||
INSERT OR IGNORE INTO mount (kind, sync_folder_id)
|
||||
VALUES ('device', NEW.id);
|
||||
END;
|
||||
";
|
||||
|
||||
/// Schema v19: photo albums and their contents. Both are projections of the
|
||||
/// server's listings — like `photos`, they are replaced wholesale on refresh —
|
||||
/// so the Albums view opens from disk instead of re-enumerating every launch.
|
||||
///
|
||||
/// `album_photos` carries its own `ratio` / `thumb_state` rather than joining
|
||||
/// `photos`: an album shared with us lives on the sharer's volume, so its
|
||||
/// photos are not in our timeline at all and would otherwise have nowhere to
|
||||
/// remember what a thumbnail attempt learned.
|
||||
const MIGRATION_V19: &str = "
|
||||
CREATE TABLE albums (
|
||||
uid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
photo_count INTEGER NOT NULL DEFAULT 0,
|
||||
cover_uid TEXT,
|
||||
last_activity INTEGER,
|
||||
shared INTEGER NOT NULL DEFAULT 0,
|
||||
seq INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE album_photos (
|
||||
album_uid TEXT NOT NULL,
|
||||
uid TEXT NOT NULL,
|
||||
capture_time INTEGER NOT NULL,
|
||||
name TEXT,
|
||||
media_type TEXT,
|
||||
kind INTEGER NOT NULL DEFAULT 0,
|
||||
ratio REAL,
|
||||
thumb_state INTEGER NOT NULL DEFAULT 0,
|
||||
seq INTEGER NOT NULL,
|
||||
PRIMARY KEY (album_uid, uid)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_album_photos_seq ON album_photos(album_uid, seq);
|
||||
CREATE INDEX idx_album_photos_uid ON album_photos(uid);
|
||||
";
|
||||
|
||||
/// V20: whether a photo is a favourite.
|
||||
///
|
||||
/// A column rather than a tag table: `Favorite` is the only Proton photo tag a
|
||||
/// user sets by hand (the rest — Video, Screenshot, Selfie, Raw… — are the
|
||||
/// server's own classification, and the Photos page already derives its tabs
|
||||
/// from the media type). Defaulting to 0 means an existing timeline stays
|
||||
/// intact and each row learns its real value on the next refresh, the same way
|
||||
/// `media_type` did.
|
||||
const MIGRATION_V20: &str = "
|
||||
ALTER TABLE photos ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0;
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_favorite ON photos(favorite, seq);
|
||||
";
|
||||
@@ -22,30 +22,34 @@ use crate::Result;
|
||||
use std::path::Path;
|
||||
|
||||
mod activity;
|
||||
mod albums;
|
||||
mod cache;
|
||||
mod devices;
|
||||
mod local;
|
||||
mod maintenance;
|
||||
mod migrations;
|
||||
mod mounts;
|
||||
mod nodes;
|
||||
mod ops;
|
||||
mod photos;
|
||||
mod pins;
|
||||
mod share_access;
|
||||
mod state;
|
||||
mod sync;
|
||||
mod trash;
|
||||
mod utils;
|
||||
|
||||
pub use albums::StoredAlbum;
|
||||
pub use cache::CacheEntryInput;
|
||||
pub use devices::StoredDevice;
|
||||
pub use local::LocalFileHit;
|
||||
pub use maintenance::{DbStats, VacuumOutcome};
|
||||
pub use nodes::{SearchHit, StoredNode};
|
||||
pub use nodes::{PublishedSharedRoot, SearchHit, StoredNode};
|
||||
pub use ops::{
|
||||
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL,
|
||||
PendingCounts, PendingOp, op_supersedes,
|
||||
PendingCounts, PendingOp, RenameMeta, op_supersedes,
|
||||
};
|
||||
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN};
|
||||
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
|
||||
pub use sync::{StoredSyncEntry, StoredSyncFolder};
|
||||
pub use trash::StoredTrash;
|
||||
|
||||
@@ -109,6 +113,10 @@ impl Db {
|
||||
#[cfg(test)]
|
||||
pub fn open_in_memory() -> Result<Self> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
// Keep relational behavior identical to `open`: V18 relies on the
|
||||
// `mount.sync_folder_id` cascade, and SQLite disables foreign keys per
|
||||
// connection unless explicitly enabled.
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
let db = Self {
|
||||
conn: Mutex::new(conn),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Unified mount/location presentation rows.
|
||||
//!
|
||||
//! A device row stores only its `sync_folder` identity. Its path, remote root,
|
||||
//! mode, state, pending mode, and last-sync timestamp are selected from
|
||||
//! `sync_folder`, which remains the authoritative sync-engine table.
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use super::Db;
|
||||
use crate::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
||||
use crate::{Error, Result};
|
||||
|
||||
impl Db {
|
||||
/// Project the configured primary mount into the presentation table.
|
||||
///
|
||||
/// `root_share_id` is optional because an offline startup may only have the
|
||||
/// cached root node. A previously learned share id is retained only when it
|
||||
/// belongs to the same root uid.
|
||||
pub fn mount_upsert_my_files(
|
||||
&self,
|
||||
local_path: &str,
|
||||
root_uid: &str,
|
||||
root_share_id: Option<&str>,
|
||||
) -> Result<i64> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let updated = tx.execute(
|
||||
"UPDATE mount
|
||||
SET local_path = ?1,
|
||||
root_share_id = CASE
|
||||
WHEN root_uid = ?2
|
||||
THEN COALESCE(NULLIF(?3, ''), root_share_id)
|
||||
ELSE COALESCE(?3, '')
|
||||
END,
|
||||
root_uid = ?2,
|
||||
mode = 'ondemand',
|
||||
access = 'rw'
|
||||
WHERE kind = 'myfiles'",
|
||||
params![local_path, root_uid, root_share_id],
|
||||
)?;
|
||||
let id = if updated == 0 {
|
||||
tx.execute(
|
||||
"INSERT INTO mount
|
||||
(kind, local_path, root_uid, root_share_id, mode, access)
|
||||
VALUES ('myfiles', ?1, ?2, COALESCE(?3, ''), 'ondemand', 'rw')",
|
||||
params![local_path, root_uid, root_share_id],
|
||||
)?;
|
||||
tx.last_insert_rowid()
|
||||
} else {
|
||||
tx.query_row("SELECT id FROM mount WHERE kind = 'myfiles'", [], |row| {
|
||||
row.get(0)
|
||||
})?
|
||||
};
|
||||
tx.commit()?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Store a share id learned asynchronously, but only if the projection still
|
||||
/// names the root it was resolved for.
|
||||
pub fn mount_repair_my_files_share_id(
|
||||
&self,
|
||||
root_uid: &str,
|
||||
root_share_id: &str,
|
||||
) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
Ok(conn.execute(
|
||||
"UPDATE mount
|
||||
SET root_share_id = ?2
|
||||
WHERE kind = 'myfiles'
|
||||
AND root_uid = ?1",
|
||||
params![root_uid, root_share_id],
|
||||
)? > 0)
|
||||
}
|
||||
|
||||
/// Every local location, with device fields joined from `sync_folder`.
|
||||
pub fn mount_list(&self) -> Result<Vec<MountSpec>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT
|
||||
m.id,
|
||||
m.kind,
|
||||
m.sync_folder_id,
|
||||
m.share_root_uid,
|
||||
CASE WHEN m.kind = 'device' THEN sf.local_path ELSE m.local_path END,
|
||||
CASE WHEN m.kind = 'device' THEN sf.remote_uid ELSE m.root_uid END,
|
||||
CASE WHEN m.kind = 'device' THEN sf.remote_share_id ELSE m.root_share_id END,
|
||||
CASE WHEN m.kind = 'device' THEN sf.mode ELSE m.mode END,
|
||||
m.access,
|
||||
CASE WHEN m.kind = 'device' THEN sf.state ELSE 'idle' END,
|
||||
CASE WHEN m.kind = 'device' THEN sf.last_sync ELSE 0 END,
|
||||
CASE WHEN m.kind = 'device' THEN sf.pending_mode ELSE NULL END
|
||||
FROM mount AS m
|
||||
LEFT JOIN sync_folder AS sf ON sf.id = m.sync_folder_id
|
||||
ORDER BY CASE m.kind
|
||||
WHEN 'myfiles' THEN 0
|
||||
WHEN 'device' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
m.id",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, Option<i64>>(2)?,
|
||||
row.get::<_, Option<String>>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
row.get::<_, String>(5)?,
|
||||
row.get::<_, String>(6)?,
|
||||
row.get::<_, String>(7)?,
|
||||
row.get::<_, String>(8)?,
|
||||
row.get::<_, String>(9)?,
|
||||
row.get::<_, i64>(10)?,
|
||||
row.get::<_, Option<String>>(11)?,
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut mounts = Vec::new();
|
||||
for row in rows {
|
||||
let (
|
||||
id,
|
||||
kind,
|
||||
sync_folder_id,
|
||||
share_root_uid,
|
||||
local_path,
|
||||
root_uid,
|
||||
root_share_id,
|
||||
mode,
|
||||
access,
|
||||
state,
|
||||
last_sync,
|
||||
pending_mode,
|
||||
) = row?;
|
||||
let kind = match kind.as_str() {
|
||||
"myfiles" => MountKind::MyFiles,
|
||||
"device" => MountKind::Device {
|
||||
sync_folder_id: sync_folder_id.ok_or_else(|| {
|
||||
Error::Other(format!("mount row {id} has no sync_folder_id"))
|
||||
})?,
|
||||
},
|
||||
"shared" => MountKind::Shared {
|
||||
share_root_uid: share_root_uid.ok_or_else(|| {
|
||||
Error::Other(format!("mount row {id} has no share_root_uid"))
|
||||
})?,
|
||||
},
|
||||
other => {
|
||||
return Err(Error::Other(format!(
|
||||
"mount row {id} has unknown kind {other:?}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let access = match access.as_str() {
|
||||
"rw" => MountAccess::Rw,
|
||||
"ro" => MountAccess::Ro,
|
||||
other => {
|
||||
return Err(Error::Other(format!(
|
||||
"mount row {id} has unknown access {other:?}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
mounts.push(MountSpec {
|
||||
id,
|
||||
kind,
|
||||
local_path,
|
||||
root_uid,
|
||||
root_share_id,
|
||||
mode: MountMode::from(mode.as_str()),
|
||||
access,
|
||||
state,
|
||||
last_sync,
|
||||
pending_mode: pending_mode.as_deref().map(MountMode::from),
|
||||
mounted: false,
|
||||
progress: None,
|
||||
});
|
||||
}
|
||||
Ok(mounts)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
//! Node rows: the persisted mirror of the remote tree, plus the trigram
|
||||
//! full-text index over names and paths that backs `Request::Search`.
|
||||
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
use std::collections::HashSet;
|
||||
|
||||
use rusqlite::{OptionalExtension, Transaction, params};
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use crate::{Access, Result};
|
||||
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||
use proton_drive_rs::{Node, NodeKind};
|
||||
|
||||
use super::utils::{TRIGRAM_MIN, collect_pairs, like_escape, pair, path_of};
|
||||
@@ -22,6 +24,11 @@ pub struct SearchHit {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
pub struct PublishedSharedRoot {
|
||||
pub node: Node,
|
||||
pub access: Access,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn upsert_node(&self, node: &Node) -> Result<()> {
|
||||
self.upsert_nodes(std::slice::from_ref(node))
|
||||
@@ -42,87 +49,188 @@ impl Db {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
for node in nodes {
|
||||
let json = serde_json::to_string(node)?;
|
||||
let uid = node.uid.to_string();
|
||||
tx.execute(
|
||||
"INSERT INTO nodes
|
||||
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(uid) DO UPDATE SET
|
||||
parent_uid = excluded.parent_uid,
|
||||
name = excluded.name,
|
||||
is_dir = excluded.is_dir,
|
||||
size = excluded.size,
|
||||
mtime = excluded.mtime,
|
||||
trashed = excluded.trashed,
|
||||
node_json = excluded.node_json",
|
||||
params![
|
||||
uid,
|
||||
node.parent_uid.as_ref().map(|u| u.to_string()),
|
||||
node.name,
|
||||
node.is_folder() as i64,
|
||||
node_size(node),
|
||||
node.modification_time,
|
||||
node.trashed as i64,
|
||||
json,
|
||||
],
|
||||
)?;
|
||||
// FTS5 has no UPSERT, so refresh the row by delete-then-insert.
|
||||
// Trashed nodes are kept out of the index entirely so they never
|
||||
// surface in search results.
|
||||
//
|
||||
// Keyed by the node's rowid, never by its uid: the uid is not an
|
||||
// indexed FTS column, so deleting by it scans the entire index once
|
||||
// per node written (B12). The rowid is stable across the upsert
|
||||
// above — `ON CONFLICT DO UPDATE` keeps the existing row — so it is
|
||||
// the same key before and after.
|
||||
let rowid: i64 = tx.query_row(
|
||||
"SELECT rowid FROM nodes WHERE uid = ?1",
|
||||
params![uid],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
// A folder rename/move changes every descendant's searchable path.
|
||||
// Refresh the affected subtree, while retaining efficient rowid
|
||||
// deletes for the overwhelmingly common one-file case.
|
||||
let mut affected = vec![rowid];
|
||||
if node.is_folder() {
|
||||
let mut stmt = tx.prepare(
|
||||
"WITH RECURSIVE descendants(rowid, uid) AS (
|
||||
SELECT rowid, uid FROM nodes WHERE parent_uid = ?1
|
||||
UNION ALL
|
||||
SELECT n.rowid, n.uid FROM nodes n
|
||||
JOIN descendants d ON n.parent_uid = d.uid
|
||||
) SELECT rowid FROM descendants",
|
||||
)?;
|
||||
for descendant in stmt.query_map(params![uid], |row| row.get(0))? {
|
||||
affected.push(descendant?);
|
||||
}
|
||||
}
|
||||
for affected_rowid in affected {
|
||||
tx.execute(
|
||||
"DELETE FROM nodes_fts WHERE rowid = ?1",
|
||||
params![affected_rowid],
|
||||
)?;
|
||||
let indexed: Option<(String, String)> = tx
|
||||
.query_row(
|
||||
"SELECT name, uid FROM nodes WHERE rowid = ?1 AND trashed = 0",
|
||||
params![affected_rowid],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some((name, indexed_uid)) = indexed {
|
||||
let path = path_of(&tx, &indexed_uid)?;
|
||||
tx.execute(
|
||||
"INSERT INTO nodes_fts (rowid, name, path) VALUES (?1, ?2, ?3)",
|
||||
params![affected_rowid, name, path],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
upsert_node_tx(&tx, node)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return visible direct children without consulting the parent's `listed`
|
||||
/// flag. Synthetic shared listings use this to serve the last completed
|
||||
/// snapshot while offline, including after a live event expired its TTL.
|
||||
pub fn visible_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT node_json FROM nodes
|
||||
WHERE parent_uid = ?1 AND trashed = 0 AND node_json IS NOT NULL
|
||||
ORDER BY name, uid",
|
||||
)?;
|
||||
let rows = stmt.query_map([parent.to_string()], |row| row.get::<_, String>(0))?;
|
||||
let mut nodes = Vec::new();
|
||||
for row in rows {
|
||||
nodes.push(serde_json::from_str(&row?)?);
|
||||
}
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
/// Atomically publish one completed `Shared with me` root listing.
|
||||
///
|
||||
/// Roots absent from `accepted` and every persisted descendant are
|
||||
/// tombstoned, not deleted. Accepted roots omitted from materialization keep
|
||||
/// their visible snapshot but are downgraded to Viewer until membership is
|
||||
/// verified again. Both cases preserve queued operations and staged writes.
|
||||
pub fn publish_shared_roots(
|
||||
&self,
|
||||
parent: &NodeUid,
|
||||
accepted: &[NodeUid],
|
||||
roots: &[PublishedSharedRoot],
|
||||
) -> Result<Vec<NodeUid>> {
|
||||
let parent = parent.to_string();
|
||||
let present: HashSet<String> = accepted.iter().map(NodeUid::to_string).collect();
|
||||
let materialized: HashSet<String> = roots
|
||||
.iter()
|
||||
.map(|published| published.node.uid.to_string())
|
||||
.collect();
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
|
||||
let existing = direct_child_uids_tx(&tx, &parent)?;
|
||||
let removed: Vec<String> = existing
|
||||
.into_iter()
|
||||
.filter(|uid| !present.contains(uid))
|
||||
.collect();
|
||||
|
||||
for published in roots {
|
||||
if !present.contains(&published.node.uid.to_string()) {
|
||||
continue;
|
||||
}
|
||||
upsert_node_tx(&tx, &published.node)?;
|
||||
upsert_share_access_tx(&tx, &published.node.uid.to_string(), published.access)?;
|
||||
}
|
||||
for uid in present.difference(&materialized) {
|
||||
upsert_share_access_tx(&tx, uid, Access::Viewer)?;
|
||||
}
|
||||
for root in &removed {
|
||||
tombstone_subtree_tx(&tx, root)?;
|
||||
upsert_share_access_tx(&tx, root, Access::Viewer)?;
|
||||
}
|
||||
tx.execute(
|
||||
"UPDATE nodes SET listed = 1 WHERE uid = ?1",
|
||||
params![parent],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
|
||||
Ok(removed
|
||||
.into_iter()
|
||||
.filter_map(|uid| parse_node_uid(&uid))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Atomically withdraw a deleted foreign subtree and deny stale handles.
|
||||
///
|
||||
/// Node rows and queued operations remain so retries and staged writes keep
|
||||
/// their references. FTS and completed-listing state are withdrawn for the
|
||||
/// whole subtree, and the deleted UID becomes a fail-closed authority.
|
||||
pub fn tombstone_foreign_subtree(&self, uid: &NodeUid) -> Result<()> {
|
||||
let uid = uid.to_string();
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
tombstone_subtree_tx(&tx, &uid)?;
|
||||
upsert_share_access_tx(&tx, &uid, Access::Viewer)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Publish a completed foreign-folder listing from its authoritative UID
|
||||
/// list and the subset that materialized successfully. Accepted-but-omitted
|
||||
/// children retain their previous snapshot; only absent UIDs are tombstoned.
|
||||
pub fn publish_foreign_children(
|
||||
&self,
|
||||
parent: &NodeUid,
|
||||
accepted: &[NodeUid],
|
||||
materialized: &[Node],
|
||||
) -> Result<Vec<NodeUid>> {
|
||||
let parent = parent.to_string();
|
||||
let present: HashSet<String> = accepted.iter().map(NodeUid::to_string).collect();
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let removed: Vec<String> = direct_child_uids_tx(&tx, &parent)?
|
||||
.into_iter()
|
||||
.filter(|uid| !present.contains(uid))
|
||||
.collect();
|
||||
for node in materialized {
|
||||
if present.contains(&node.uid.to_string()) {
|
||||
upsert_node_tx(&tx, node)?;
|
||||
}
|
||||
}
|
||||
for uid in &removed {
|
||||
tombstone_subtree_tx(&tx, uid)?;
|
||||
}
|
||||
tx.execute(
|
||||
"UPDATE nodes SET listed = 1 WHERE uid = ?1",
|
||||
params![parent],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(removed
|
||||
.into_iter()
|
||||
.filter_map(|uid| parse_node_uid(&uid))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Atomically publish the synthetic root's pinned name, node/FTS state, and
|
||||
/// Viewer authority.
|
||||
///
|
||||
/// The pin is inserted last so any failure there rolls back the node and
|
||||
/// its descendant FTS updates as well as the access row. Returning `false`
|
||||
/// guarantees no write occurred, which keeps cached root lookups O(1).
|
||||
pub fn publish_virtual_root(&self, pinned_name_key: &str, node: &Node) -> Result<bool> {
|
||||
let uid = node.uid.to_string();
|
||||
let desired_parent = node.parent_uid.as_ref().map(NodeUid::to_string);
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let pinned_name: Option<String> = tx
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = ?1",
|
||||
params![pinned_name_key],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
let stored: Option<(Option<String>, String, i64, i64)> = tx
|
||||
.query_row(
|
||||
"SELECT parent_uid, name, is_dir, trashed FROM nodes WHERE uid = ?1",
|
||||
params![uid],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||
)
|
||||
.optional()?;
|
||||
let access: Option<String> = tx
|
||||
.query_row(
|
||||
"SELECT access FROM share_access WHERE root_uid = ?1",
|
||||
params![uid],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
let unchanged = stored.is_some_and(|(parent, name, is_dir, trashed)| {
|
||||
parent == desired_parent
|
||||
&& name == node.name
|
||||
&& is_dir == 1
|
||||
&& trashed == node.trashed as i64
|
||||
}) && access.as_deref() == Some(Access::Viewer.as_db_str())
|
||||
&& pinned_name.is_some();
|
||||
if unchanged {
|
||||
tx.commit()?;
|
||||
return Ok(false);
|
||||
}
|
||||
upsert_share_access_tx(&tx, &uid, Access::Viewer)?;
|
||||
upsert_node_tx(&tx, node)?;
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES (?1, ?2)
|
||||
ON CONFLICT(key) DO NOTHING",
|
||||
params![pinned_name_key, node.name],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Drop a node row (delete or trash from the hot cache). Children rows are
|
||||
/// not cascaded here; the daemon forgets a whole subtree node-by-node.
|
||||
pub fn delete_node(&self, uid: &NodeUid) -> Result<()> {
|
||||
@@ -148,6 +256,32 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomically retire a landed trash operation and its retained authority.
|
||||
///
|
||||
/// Queued trash keeps the node row so drain-time permission checks can
|
||||
/// resolve its shared-tree access. Removing the op first could resurrect
|
||||
/// that row after a crash; removing the row first could strand a now
|
||||
/// unauthorizable op. One transaction closes both windows.
|
||||
pub fn complete_trash_op(&self, op_id: i64, uid: &NodeUid) -> Result<()> {
|
||||
let uid = uid.to_string();
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let rowid: Option<i64> = tx
|
||||
.query_row(
|
||||
"SELECT rowid FROM nodes WHERE uid = ?1",
|
||||
params![uid],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
tx.execute("DELETE FROM nodes WHERE uid = ?1", params![uid])?;
|
||||
if let Some(rowid) = rowid {
|
||||
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
|
||||
}
|
||||
tx.execute("DELETE FROM pending_op WHERE id = ?1", params![op_id])?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a folder node has any non-trashed children in the database.
|
||||
pub fn has_children(&self, parent_uid: &NodeUid) -> Result<bool> {
|
||||
let uid_str = parent_uid.to_string();
|
||||
@@ -387,6 +521,168 @@ impl Db {
|
||||
// in sync on every store/read/evict, so it is authoritative for eviction.
|
||||
}
|
||||
|
||||
fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
||||
let json = serde_json::to_string(node)?;
|
||||
let uid = node.uid.to_string();
|
||||
tx.execute(
|
||||
"INSERT INTO nodes
|
||||
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(uid) DO UPDATE SET
|
||||
parent_uid = excluded.parent_uid,
|
||||
name = excluded.name,
|
||||
is_dir = excluded.is_dir,
|
||||
size = excluded.size,
|
||||
mtime = excluded.mtime,
|
||||
trashed = excluded.trashed,
|
||||
node_json = excluded.node_json",
|
||||
params![
|
||||
uid,
|
||||
node.parent_uid.as_ref().map(|u| u.to_string()),
|
||||
node.name,
|
||||
node.is_folder() as i64,
|
||||
node_size(node),
|
||||
node.modification_time,
|
||||
node.trashed as i64,
|
||||
json,
|
||||
],
|
||||
)?;
|
||||
// FTS5 has no UPSERT. Folder moves also change every descendant path, so
|
||||
// refresh the whole affected subtree while keeping rowid deletes indexed.
|
||||
let rowid: i64 = tx.query_row(
|
||||
"SELECT rowid FROM nodes WHERE uid = ?1",
|
||||
params![uid],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let mut affected = vec![rowid];
|
||||
if node.is_folder() {
|
||||
let mut stmt = tx.prepare(
|
||||
"WITH RECURSIVE descendants(rowid, uid) AS (
|
||||
SELECT rowid, uid FROM nodes WHERE parent_uid = ?1
|
||||
UNION ALL
|
||||
SELECT n.rowid, n.uid FROM nodes n
|
||||
JOIN descendants d ON n.parent_uid = d.uid
|
||||
) SELECT rowid FROM descendants",
|
||||
)?;
|
||||
for descendant in stmt.query_map(params![uid], |row| row.get(0))? {
|
||||
affected.push(descendant?);
|
||||
}
|
||||
}
|
||||
for affected_rowid in affected {
|
||||
tx.execute(
|
||||
"DELETE FROM nodes_fts WHERE rowid = ?1",
|
||||
params![affected_rowid],
|
||||
)?;
|
||||
let indexed: Option<(String, String)> = tx
|
||||
.query_row(
|
||||
"SELECT name, uid FROM nodes WHERE rowid = ?1",
|
||||
params![affected_rowid],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some((name, indexed_uid)) = indexed
|
||||
&& node_is_indexable_tx(tx, &indexed_uid)?
|
||||
{
|
||||
let path = path_of(tx, &indexed_uid)?;
|
||||
tx.execute(
|
||||
"INSERT INTO nodes_fts (rowid, name, path) VALUES (?1, ?2, ?3)",
|
||||
params![affected_rowid, name, path],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn direct_child_uids_tx(tx: &Transaction<'_>, parent: &str) -> Result<Vec<String>> {
|
||||
let mut stmt = tx.prepare("SELECT uid FROM nodes WHERE parent_uid = ?1")?;
|
||||
let rows = stmt.query_map([parent], |row| row.get::<_, String>(0))?;
|
||||
let mut uids = Vec::new();
|
||||
for row in rows {
|
||||
uids.push(row?);
|
||||
}
|
||||
Ok(uids)
|
||||
}
|
||||
|
||||
fn node_is_indexable_tx(tx: &Transaction<'_>, uid: &str) -> Result<bool> {
|
||||
let indexable: i64 = tx.query_row(
|
||||
"WITH RECURSIVE ancestors(uid, parent_uid, trashed, path) AS (
|
||||
SELECT uid, parent_uid, trashed, char(31) || uid || char(31)
|
||||
FROM nodes WHERE uid = ?1
|
||||
UNION ALL
|
||||
SELECT n.uid, n.parent_uid, n.trashed,
|
||||
a.path || n.uid || char(31)
|
||||
FROM ancestors a JOIN nodes n ON n.uid = a.parent_uid
|
||||
WHERE instr(a.path, char(31) || n.uid || char(31)) = 0
|
||||
)
|
||||
SELECT CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
WHEN MAX(trashed) != 0 THEN 0
|
||||
WHEN MAX(CASE WHEN parent_uid IS NULL THEN 1 ELSE 0 END) = 0 THEN 0
|
||||
ELSE 1
|
||||
END
|
||||
FROM ancestors",
|
||||
[uid],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(indexable == 1)
|
||||
}
|
||||
|
||||
fn tombstone_subtree_tx(tx: &Transaction<'_>, root_uid: &str) -> Result<()> {
|
||||
let rows = {
|
||||
let mut stmt = tx.prepare(
|
||||
"WITH RECURSIVE subtree(rowid, uid, node_json) AS (
|
||||
SELECT rowid, uid, node_json FROM nodes WHERE uid = ?1
|
||||
UNION
|
||||
SELECT n.rowid, n.uid, n.node_json FROM nodes n
|
||||
JOIN subtree s ON n.parent_uid = s.uid
|
||||
)
|
||||
SELECT rowid, uid, node_json FROM subtree",
|
||||
)?;
|
||||
let mapped = stmt.query_map([root_uid], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
))
|
||||
})?;
|
||||
let mut rows = Vec::new();
|
||||
for row in mapped {
|
||||
rows.push(row?);
|
||||
}
|
||||
rows
|
||||
};
|
||||
for (rowid, uid, json) in rows {
|
||||
let json = match json {
|
||||
Some(json) => {
|
||||
let mut node: Node = serde_json::from_str(&json)?;
|
||||
node.trashed = true;
|
||||
Some(serde_json::to_string(&node)?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
tx.execute(
|
||||
"UPDATE nodes SET trashed = 1, listed = 0, node_json = ?2 WHERE uid = ?1",
|
||||
params![uid, json],
|
||||
)?;
|
||||
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", [rowid])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn upsert_share_access_tx(tx: &Transaction<'_>, uid: &str, access: Access) -> Result<()> {
|
||||
tx.execute(
|
||||
"INSERT INTO share_access (root_uid, access) VALUES (?1, ?2)
|
||||
ON CONFLICT(root_uid) DO UPDATE SET access = excluded.access",
|
||||
params![uid, access.as_db_str()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_node_uid(value: &str) -> Option<NodeUid> {
|
||||
let (volume, link) = value.split_once('~')?;
|
||||
Some(NodeUid::new(VolumeId::from(volume), LinkId::from(link)))
|
||||
}
|
||||
|
||||
/// Unique quoted character trigrams suitable for an FTS5 OR expression.
|
||||
pub(super) fn candidate_trigrams(query: &str) -> Vec<String> {
|
||||
let chars: Vec<char> = query.to_lowercase().chars().collect();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! order, so a child never drains before the parent that gives it a real uid.
|
||||
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
@@ -35,6 +36,16 @@ pub const OP_MKDIR: &str = "mkdir";
|
||||
/// that folder's own drain, exactly as for [`OP_CREATE`].
|
||||
pub const OP_RENAME: &str = "rename";
|
||||
|
||||
/// Drain-time authority retained by a queued rename.
|
||||
///
|
||||
/// The optimistic local move rewrites the node's persisted ancestry to the
|
||||
/// destination immediately. Keeping the original parent here prevents that
|
||||
/// rewrite from erasing the shared tree that admitted the move.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RenameMeta {
|
||||
pub original_parent_uid: String,
|
||||
}
|
||||
|
||||
/// The `kind` of a [`PendingOp`] that trashes a node the server knows about
|
||||
/// (offline.md Phase 3b).
|
||||
///
|
||||
@@ -127,6 +138,32 @@ pub struct AttachedBlob {
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Whether a specific desired-state operation is still queued for a node.
|
||||
pub fn has_pending_op(&self, uid: &str, kind: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM pending_op WHERE uid = ?1 AND kind = ?2
|
||||
)",
|
||||
params![uid, kind],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Read an existing operation's metadata before a superseding enqueue.
|
||||
pub fn pending_op_meta(&self, uid: &str, kind: &str) -> Result<Option<String>> {
|
||||
let conn = self.conn.lock();
|
||||
conn.query_row(
|
||||
"SELECT meta_json FROM pending_op WHERE uid = ?1 AND kind = ?2",
|
||||
params![uid, kind],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map(|value| value.flatten())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn enqueue_op(&self, op: &PendingOp) -> Result<(i64, Option<String>)> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
@@ -167,6 +204,50 @@ impl Db {
|
||||
Ok((id, superseded))
|
||||
}
|
||||
|
||||
/// Atomically replace every queued operation for `uid` and its queued
|
||||
/// descendants with one remote trash intent.
|
||||
///
|
||||
/// Staged blob paths are returned only after the transaction commits. A
|
||||
/// failed trash insert therefore rolls the deletions back and leaves every
|
||||
/// prior operation owning its bytes.
|
||||
pub fn replace_ops_with_trash(
|
||||
&self,
|
||||
uid: &str,
|
||||
name: &str,
|
||||
created_at: i64,
|
||||
) -> Result<(i64, Vec<String>)> {
|
||||
const SUBTREE: &str = "
|
||||
WITH RECURSIVE doomed(uid) AS (
|
||||
SELECT ?1
|
||||
UNION
|
||||
SELECT p.uid FROM pending_op p JOIN doomed d ON p.parent_uid = d.uid
|
||||
)";
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let blobs: Vec<String> = {
|
||||
let mut stmt = tx.prepare(&format!(
|
||||
"{SUBTREE}
|
||||
SELECT blob_path FROM pending_op
|
||||
WHERE uid IN (SELECT uid FROM doomed) AND blob_path IS NOT NULL"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![uid], |row| row.get::<_, String>(0))?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
};
|
||||
tx.execute(
|
||||
&format!("{SUBTREE} DELETE FROM pending_op WHERE uid IN (SELECT uid FROM doomed)"),
|
||||
params![uid],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO pending_op
|
||||
(kind, uid, parent_uid, name, blob_path, meta_json, created_at, next_attempt_at)
|
||||
VALUES (?1, ?2, NULL, ?3, NULL, NULL, ?4, 0)",
|
||||
params![OP_TRASH, uid, name, created_at],
|
||||
)?;
|
||||
let id = tx.last_insert_rowid();
|
||||
tx.commit()?;
|
||||
Ok((id, blobs))
|
||||
}
|
||||
|
||||
/// Point a queued create at the bytes that were just written to it, returning
|
||||
/// any blob it previously held so the caller can discard it.
|
||||
///
|
||||
@@ -509,4 +590,23 @@ impl Db {
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Defer a due operation without recording a failed remote attempt.
|
||||
///
|
||||
/// Access checks happen before the drain calls Proton, so a locally denied
|
||||
/// operation has not failed remotely and must not consume a retry. Preserve
|
||||
/// the long-lived transient-create sentinel if this is ever called for one.
|
||||
pub fn defer_op_without_attempt(&self, id: i64, next_attempt_at: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE pending_op
|
||||
SET next_attempt_at = CASE
|
||||
WHEN next_attempt_at >= ?3 THEN next_attempt_at
|
||||
ELSE ?2
|
||||
END
|
||||
WHERE id = ?1",
|
||||
params![id, next_attempt_at, PARK_UNTIL],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -32,13 +32,21 @@ pub struct StoredPhoto {
|
||||
/// Which Photos-page tab this entry belongs to, derived from its name and
|
||||
/// media type when the timeline was last replaced.
|
||||
pub kind: crate::control::PhotoKind,
|
||||
/// Whether the photo carries Proton's `Favorite` tag.
|
||||
pub favorite: bool,
|
||||
}
|
||||
|
||||
/// One row of a timeline replacement: `(uid, capture_time, name, media_type,
|
||||
/// favorite)`. The two trailing options are "what this refresh learned" — a
|
||||
/// `None` keeps whatever is already stored rather than clearing it.
|
||||
pub type TimelineRow = (String, i64, Option<String>, Option<String>, Option<bool>);
|
||||
|
||||
impl Db {
|
||||
pub fn photos_replace(
|
||||
&self,
|
||||
items: &[(String, i64, Option<String>, Option<String>)],
|
||||
) -> Result<()> {
|
||||
/// Replace the timeline wholesale. Each item is
|
||||
/// `(uid, capture_time, name, media_type, favorite)`; `favorite` is `None`
|
||||
/// when the refresh could not resolve that photo's node, in which case the
|
||||
/// flag already stored is kept rather than silently cleared.
|
||||
pub fn photos_replace(&self, items: &[TimelineRow]) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
// `media_type` is learned-and-kept like the ratio and thumb verdict: the
|
||||
@@ -46,10 +54,14 @@ impl Db {
|
||||
// not know a photo's media type yet when it replaces the timeline. Keep
|
||||
// any previously learned value so the Photos/Videos/Raw split survives a
|
||||
// refresh instead of collapsing back to name-extension guesses.
|
||||
let learned: HashMap<String, (Option<f64>, i64, Option<String>)> = {
|
||||
let mut stmt = tx.prepare("SELECT uid, ratio, thumb_state, media_type FROM photos")?;
|
||||
let learned: HashMap<String, (Option<f64>, i64, Option<String>, bool)> = {
|
||||
let mut stmt =
|
||||
tx.prepare("SELECT uid, ratio, thumb_state, media_type, favorite FROM photos")?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?, r.get(3)?)))
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
(r.get(1)?, r.get(2)?, r.get(3)?, r.get::<_, i64>(4)? != 0),
|
||||
))
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
@@ -58,16 +70,16 @@ impl Db {
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO photos
|
||||
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
)?;
|
||||
for (seq, (uid, capture_time, name, media_type)) in items.iter().enumerate() {
|
||||
let (ratio, thumb_state, learned_media) =
|
||||
learned
|
||||
.get(uid)
|
||||
.cloned()
|
||||
.unwrap_or((None, THUMB_UNKNOWN, None));
|
||||
for (seq, (uid, capture_time, name, media_type, favorite)) in items.iter().enumerate() {
|
||||
let (ratio, thumb_state, learned_media, learned_favorite) = learned
|
||||
.get(uid)
|
||||
.cloned()
|
||||
.unwrap_or((None, THUMB_UNKNOWN, None, false));
|
||||
let media_type = media_type.clone().or(learned_media);
|
||||
let favorite = favorite.unwrap_or(learned_favorite);
|
||||
// The tab this photo lands in is derived here, once, so a page or
|
||||
// count query is a plain indexed `WHERE kind = ?` rather than a
|
||||
// reclassification of every row.
|
||||
@@ -82,6 +94,7 @@ impl Db {
|
||||
seq as i64,
|
||||
media_type,
|
||||
kind.as_i64(),
|
||||
favorite as i64,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
@@ -93,18 +106,21 @@ impl Db {
|
||||
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
|
||||
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
|
||||
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
|
||||
/// `favorites` restricts the page to favourited photos.
|
||||
pub fn photos_page(
|
||||
&self,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
kind: Option<crate::control::PhotoKind>,
|
||||
range: Option<(i64, i64)>,
|
||||
favorites: bool,
|
||||
) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.conn.lock();
|
||||
// Built up so any combination of the optional filters is one indexed
|
||||
// query rather than a statement per case.
|
||||
let mut sql =
|
||||
String::from("SELECT uid, capture_time, name, ratio, thumb_state, kind FROM photos");
|
||||
let mut sql = String::from(
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos",
|
||||
);
|
||||
let mut binds: Vec<i64> = Vec::new();
|
||||
let mut conds: Vec<String> = Vec::new();
|
||||
if let Some(k) = kind {
|
||||
@@ -117,6 +133,9 @@ impl Db {
|
||||
binds.push(to);
|
||||
conds.push(format!("capture_time < ?{}", binds.len()));
|
||||
}
|
||||
if favorites {
|
||||
conds.push("favorite = 1".to_string());
|
||||
}
|
||||
if !conds.is_empty() {
|
||||
sql.push_str(" WHERE ");
|
||||
sql.push_str(&conds.join(" AND "));
|
||||
@@ -139,6 +158,7 @@ impl Db {
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||
favorite: r.get::<_, i64>(6)? != 0,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
@@ -206,7 +226,7 @@ impl Db {
|
||||
let conn = self.conn.lock();
|
||||
let placeholders = vec!["?"; uids.len()].join(",");
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM photos
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos
|
||||
WHERE uid IN ({placeholders})"
|
||||
))?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(uids), |r| {
|
||||
@@ -217,6 +237,7 @@ impl Db {
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||
favorite: r.get::<_, i64>(6)? != 0,
|
||||
})
|
||||
})?;
|
||||
let mut photos = Vec::new();
|
||||
@@ -226,6 +247,18 @@ impl Db {
|
||||
Ok(photos)
|
||||
}
|
||||
|
||||
/// Record a photo's favourite flag locally, after the server accepted the
|
||||
/// change. A uid the timeline does not hold is a no-op — an album photo on
|
||||
/// someone else's volume is never in our own `photos` table.
|
||||
pub fn photos_set_favorite(&self, uid: &str, favorite: bool) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE photos SET favorite = ?2 WHERE uid = ?1",
|
||||
params![uid, favorite as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record what a thumbnail attempt learned: whether the photo now has one
|
||||
/// ([`THUMB_HAVE`] / [`THUMB_NONE`]), and its aspect ratio if the pixels were
|
||||
/// seen. A `None` ratio leaves any previously learned one alone.
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Persisted effective access for roots shared with this account.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
|
||||
use super::Db;
|
||||
use crate::{Access, Error, Result};
|
||||
|
||||
impl Db {
|
||||
/// Load the complete, deliberately small shared-root authority map.
|
||||
///
|
||||
/// Mount state keeps this resident so ordinary node interning and subtree
|
||||
/// propagation never turn into one SQLite query per inode. The database
|
||||
/// remains the restart/offline authority and every explicit role change is
|
||||
/// written through before this cache is updated.
|
||||
pub fn all_share_access(&self) -> Result<HashMap<NodeUid, Access>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare("SELECT root_uid, access FROM share_access")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})?;
|
||||
let mut access = HashMap::new();
|
||||
for row in rows {
|
||||
let (uid, value) = row?;
|
||||
let (volume, link) = uid.split_once('~').ok_or_else(|| {
|
||||
Error::Other(format!(
|
||||
"invalid shared-root uid {uid:?} stored in database"
|
||||
))
|
||||
})?;
|
||||
let access_value = Access::from_db_str(&value).ok_or_else(|| {
|
||||
Error::Other(format!("invalid share access {value:?} stored for {uid}"))
|
||||
})?;
|
||||
access.insert(
|
||||
NodeUid::new(VolumeId::from(volume), LinkId::from(link)),
|
||||
access_value,
|
||||
);
|
||||
}
|
||||
Ok(access)
|
||||
}
|
||||
|
||||
/// Read the effective access recorded for a shared-tree root.
|
||||
pub fn share_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
||||
let conn = self.conn.lock();
|
||||
let value = conn
|
||||
.query_row(
|
||||
"SELECT access FROM share_access WHERE root_uid = ?1",
|
||||
[uid.to_string()],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?;
|
||||
value
|
||||
.map(|value| {
|
||||
Access::from_db_str(&value).ok_or_else(|| {
|
||||
Error::Other(format!("invalid share access {value:?} stored for {uid}"))
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Insert or replace the effective access for a shared-tree root.
|
||||
pub fn set_share_access(&self, uid: &NodeUid, access: Access) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO share_access (root_uid, access) VALUES (?1, ?2)
|
||||
ON CONFLICT(root_uid) DO UPDATE SET access = excluded.access",
|
||||
params![uid.to_string(), access.as_db_str()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget a shared-tree root's effective access.
|
||||
pub fn delete_share_access(&self, uid: &NodeUid) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"DELETE FROM share_access WHERE root_uid = ?1",
|
||||
[uid.to_string()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fail closed every persisted shared-tree authority.
|
||||
///
|
||||
/// Owned and device trees have no row in `share_access`, so this only
|
||||
/// affects roots already identified as shared with this account.
|
||||
pub fn downgrade_all_share_access(&self) -> Result<usize> {
|
||||
let conn = self.conn.lock();
|
||||
Ok(conn.execute(
|
||||
"UPDATE share_access SET access = 'viewer' WHERE access != 'viewer'",
|
||||
[],
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Resolve a persisted node's effective access through its nearest recorded
|
||||
/// share root. `None` means the node itself is not persisted, which callers
|
||||
/// must not treat as owned: stale handles need to fail closed.
|
||||
pub fn effective_node_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
||||
let conn = self.conn.lock();
|
||||
let exists: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM nodes WHERE uid = ?1)",
|
||||
[uid.to_string()],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !exists {
|
||||
return Ok(None);
|
||||
}
|
||||
let value = conn
|
||||
.query_row(
|
||||
"WITH RECURSIVE ancestors(uid, parent_uid, depth, path) AS (
|
||||
SELECT uid, parent_uid, 0, char(31) || uid || char(31)
|
||||
FROM nodes WHERE uid = ?1
|
||||
UNION ALL
|
||||
SELECT a.parent_uid, n.parent_uid, a.depth + 1,
|
||||
a.path || a.parent_uid || char(31)
|
||||
FROM ancestors a
|
||||
LEFT JOIN nodes n ON n.uid = a.parent_uid
|
||||
WHERE a.parent_uid IS NOT NULL
|
||||
AND instr(
|
||||
a.path,
|
||||
char(31) || a.parent_uid || char(31)
|
||||
) = 0
|
||||
)
|
||||
SELECT sa.access
|
||||
FROM ancestors a JOIN share_access sa ON sa.root_uid = a.uid
|
||||
ORDER BY a.depth ASC
|
||||
LIMIT 1",
|
||||
[uid.to_string()],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?;
|
||||
value
|
||||
.map(|value| {
|
||||
Access::from_db_str(&value).ok_or_else(|| {
|
||||
Error::Other(format!(
|
||||
"invalid effective share access {value:?} for {uid}"
|
||||
))
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
.map(|access| Some(access.unwrap_or(Access::Owner)))
|
||||
}
|
||||
}
|
||||
@@ -94,4 +94,26 @@ impl Db {
|
||||
conn.execute("DELETE FROM sync_state WHERE key = ?1", params![key])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop every `sync_state` key starting with `prefix` — the per-entity
|
||||
/// stamps (one album, one shared folder) that are keyed by uid and so have
|
||||
/// no fixed name to clear individually.
|
||||
pub fn clear_state_prefix(&self, prefix: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
// `\` escapes the LIKE wildcards, so a prefix containing `%` or `_`
|
||||
// (a uid never does today, but this is not the place to rely on that)
|
||||
// matches literally instead of sweeping unrelated keys.
|
||||
let pattern = format!(
|
||||
"{}%",
|
||||
prefix
|
||||
.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_")
|
||||
);
|
||||
conn.execute(
|
||||
"DELETE FROM sync_state WHERE key LIKE ?1 ESCAPE '\\'",
|
||||
params![pattern],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -115,14 +115,17 @@ impl Db {
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Update a synced folder's mode (`mirror`/`ondemand`). The folder has
|
||||
/// reached the mode it was asked for, so any queued request is satisfied and
|
||||
/// cleared in the same write — a `pending_mode` outliving the switch it asked
|
||||
/// for would have the engine try to apply it again on the next pass.
|
||||
/// Update a synced folder's mode (`mirror`/`ondemand`). A queued request for
|
||||
/// that mode is satisfied and cleared in the same write. An opposite pending
|
||||
/// mode arrived after this transition started, so preserve it as the newer
|
||||
/// last-request intent.
|
||||
pub fn sync_folder_set_mode(&self, id: i64, mode: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE sync_folder SET mode = ?2, pending_mode = NULL WHERE id = ?1",
|
||||
"UPDATE sync_folder
|
||||
SET mode = ?2,
|
||||
pending_mode = CASE WHEN pending_mode = ?2 THEN NULL ELSE pending_mode END
|
||||
WHERE id = ?1",
|
||||
params![id, mode],
|
||||
)?;
|
||||
Ok(())
|
||||
@@ -139,6 +142,18 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Withdraw a queued mode only if it is still the intent the caller
|
||||
/// inspected. A newer request must survive an older settlement attempt.
|
||||
pub fn sync_folder_clear_pending_mode_if(&self, id: i64, expected: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
let changed = conn.execute(
|
||||
"UPDATE sync_folder SET pending_mode = NULL
|
||||
WHERE id = ?1 AND pending_mode = ?2",
|
||||
params![id, expected],
|
||||
)?;
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
/// Update a synced folder's state and stamp `last_sync` to now.
|
||||
pub fn sync_folder_set_state(&self, id: i64, state: &str, last_sync: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
@@ -174,6 +189,28 @@ impl Db {
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Whether `uid` is represented by a mirror location.
|
||||
///
|
||||
/// An on-demand location proves residency through its live inode state.
|
||||
/// Mirror locations have no FUSE state, so their folder root comes from
|
||||
/// `sync_folder` and descendants come from the last successful
|
||||
/// `sync_entry` baseline.
|
||||
pub fn mirror_contains_uid(&self, uid: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM sync_folder AS folder
|
||||
LEFT JOIN sync_entry AS entry ON entry.folder_id = folder.id
|
||||
WHERE folder.mode = 'mirror'
|
||||
AND (folder.remote_uid = ?1 OR entry.remote_uid = ?1)
|
||||
)",
|
||||
params![uid],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Insert or replace one baseline row.
|
||||
pub fn sync_entry_upsert(&self, folder_id: i64, e: &StoredSyncEntry) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
|
||||
+1199
-12
File diff suppressed because it is too large.
Load diff
@@ -2,19 +2,24 @@
|
||||
//! identity, XDG paths, and the login/session/keyring layer that every
|
||||
//! front-end (CLI today, daemon + GUI later) shares.
|
||||
|
||||
pub mod access;
|
||||
pub mod auth;
|
||||
pub mod batch;
|
||||
pub mod cache;
|
||||
pub mod config;
|
||||
pub mod control;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod localindex;
|
||||
pub mod mounts;
|
||||
pub mod profile;
|
||||
pub mod sdkcache;
|
||||
pub mod search;
|
||||
pub mod service;
|
||||
pub mod shell;
|
||||
pub mod syncignore;
|
||||
|
||||
pub use access::{Access, access_for, perm_bits};
|
||||
pub use error::{CoreError, CoreResult, Error, Result};
|
||||
|
||||
// Re-export the SDK surface downstream crates need, so they depend on
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Unified presentation model for every local Proton Drive location.
|
||||
//!
|
||||
//! Device sync state remains owned by `sync_folder`; these types are the stable
|
||||
//! shape exposed to front-ends after that state is joined with the `mount`
|
||||
//! presentation table.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::control::SyncProgress;
|
||||
|
||||
/// How a location makes remote content available locally.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MountMode {
|
||||
/// A full local copy reconciled in both directions.
|
||||
Mirror,
|
||||
/// A FUSE session that fetches content as it is accessed.
|
||||
OnDemand,
|
||||
/// A mode written by a newer client.
|
||||
#[default]
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl MountMode {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Mirror => "mirror",
|
||||
Self::OnDemand => "ondemand",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for MountMode {
|
||||
fn from(value: &str) -> Self {
|
||||
match value {
|
||||
"mirror" => Self::Mirror,
|
||||
"ondemand" => Self::OnDemand,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MountMode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the location itself was declared writable.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MountAccess {
|
||||
Rw,
|
||||
Ro,
|
||||
}
|
||||
|
||||
impl MountAccess {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Rw => "rw",
|
||||
Self::Ro => "ro",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MountAccess {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// What remote root a local location presents.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||
pub enum MountKind {
|
||||
/// The account's primary My Files mount.
|
||||
MyFiles,
|
||||
/// A folder owned by this machine's Proton Drive device.
|
||||
Device { sync_folder_id: i64 },
|
||||
/// A future standalone mount rooted at a node shared with this account.
|
||||
Shared { share_root_uid: String },
|
||||
}
|
||||
|
||||
/// One local Proton Drive location as exposed over the control protocol.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct MountSpec {
|
||||
/// Presentation-row id. Device actions use the `sync_folder_id` carried by
|
||||
/// [`MountKind::Device`], not this id.
|
||||
pub id: i64,
|
||||
pub kind: MountKind,
|
||||
pub local_path: String,
|
||||
pub root_uid: String,
|
||||
pub root_share_id: String,
|
||||
pub mode: MountMode,
|
||||
pub access: MountAccess,
|
||||
/// `idle` | `syncing` | `error` | `conflict` for device locations.
|
||||
pub state: String,
|
||||
/// Last successful device sync, epoch seconds; `0` when not applicable.
|
||||
pub last_sync: i64,
|
||||
/// A device mode switch accepted but not yet applied.
|
||||
#[serde(default)]
|
||||
pub pending_mode: Option<MountMode>,
|
||||
/// Whether a FUSE session currently owns `local_path`.
|
||||
#[serde(default)]
|
||||
pub mounted: bool,
|
||||
/// Live device sync progress, absent when no pass is running.
|
||||
#[serde(default)]
|
||||
pub progress: Option<SyncProgress>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mount_mode_keeps_legacy_wire_strings() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&MountMode::Mirror).unwrap(),
|
||||
r#""mirror""#
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&MountMode::OnDemand).unwrap(),
|
||||
r#""ondemand""#
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<MountMode>(r#""mirror""#).unwrap(),
|
||||
MountMode::Mirror
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<MountMode>(r#""ondemand""#).unwrap(),
|
||||
MountMode::OnDemand
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mount_mode_tolerates_unknown_values() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<MountMode>(r#""from-the-future""#).unwrap(),
|
||||
MountMode::Unknown
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,20 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// File name of the profile inside the device root folder.
|
||||
use crate::mounts::MountMode;
|
||||
|
||||
/// File name of the profile.
|
||||
pub const PROFILE_FILE_NAME: &str = "profile.json";
|
||||
|
||||
/// Folder, directly under the device root, that holds the profile.
|
||||
///
|
||||
/// The API refuses `Cannot create file at the root of a device` (HTTP 422), so
|
||||
/// the profile cannot live at the device root itself even though that is where
|
||||
/// it logically belongs (docs/BUGS.md B68/B78). A dotted name keeps it out of
|
||||
/// the way in the web UI, and the restore ignores it when listing the device's
|
||||
/// restorable folders.
|
||||
pub const PROFILE_DIR_NAME: &str = ".proton-drive-linux";
|
||||
|
||||
/// Format version of the profile document.
|
||||
///
|
||||
/// Bumped only for changes a *reader* cannot survive. Adding fields does not
|
||||
@@ -39,8 +50,9 @@ pub struct ProfileFolder {
|
||||
/// Absolute local path it was synced to. A *suggestion* on restore: another
|
||||
/// machine may have a different username, home, or mount layout.
|
||||
pub local_path: String,
|
||||
/// `mirror` or `ondemand`.
|
||||
pub mode: String,
|
||||
/// `mirror` or `ondemand`. Unknown values written by a newer client do not
|
||||
/// invalidate the rest of the profile.
|
||||
pub mode: MountMode,
|
||||
}
|
||||
|
||||
/// One pinned node (P5 pins), as `pins` stores it.
|
||||
@@ -114,7 +126,7 @@ mod tests {
|
||||
folders: vec![ProfileFolder {
|
||||
remote_uid: "vol~link".to_string(),
|
||||
local_path: "/home/nils/Documents".to_string(),
|
||||
mode: "mirror".to_string(),
|
||||
mode: MountMode::Mirror,
|
||||
}],
|
||||
pins: vec![ProfilePin {
|
||||
uid: "vol~pin".to_string(),
|
||||
@@ -133,6 +145,52 @@ mod tests {
|
||||
assert_eq!(Profile::parse(&p.to_bytes().unwrap()).unwrap(), p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_keeps_legacy_mode_bytes_and_parses_old_documents() {
|
||||
let bytes = sample().to_bytes().unwrap();
|
||||
let json = String::from_utf8(bytes).unwrap();
|
||||
assert!(json.contains(r#""mode": "mirror""#));
|
||||
|
||||
let old = br#"{
|
||||
"version": 1,
|
||||
"device_uid": "dev-1",
|
||||
"hostname": "old-client",
|
||||
"saved_at": 1,
|
||||
"folders": [
|
||||
{
|
||||
"remote_uid": "vol~mirror",
|
||||
"local_path": "/home/me/Mirror",
|
||||
"mode": "mirror"
|
||||
},
|
||||
{
|
||||
"remote_uid": "vol~ondemand",
|
||||
"local_path": "/home/me/Cloud",
|
||||
"mode": "ondemand"
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
let profile = Profile::parse(old).unwrap();
|
||||
assert_eq!(profile.folders[0].mode, MountMode::Mirror);
|
||||
assert_eq!(profile.folders[1].mode, MountMode::OnDemand);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_folder_mode_does_not_reject_profile() {
|
||||
let json = br#"{
|
||||
"version": 1,
|
||||
"device_uid": "dev-1",
|
||||
"hostname": "future-client",
|
||||
"saved_at": 1,
|
||||
"folders": [{
|
||||
"remote_uid": "vol~future",
|
||||
"local_path": "/home/me/Future",
|
||||
"mode": "streaming"
|
||||
}]
|
||||
}"#;
|
||||
let profile = Profile::parse(json).unwrap();
|
||||
assert_eq!(profile.folders[0].mode, MountMode::Unknown);
|
||||
}
|
||||
|
||||
/// A field added by a later client must not break this one's restore: the
|
||||
/// common subset is still usable, which is the whole reason `version` is
|
||||
/// reserved for breaking changes only.
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
//! A persistent [`CacheRepository`] for the SDK's Drive entity cache.
|
||||
//!
|
||||
//! The SDK caches decrypted node metadata (name, size, parent, the share whose
|
||||
//! membership signs operations on it) so navigating a tree does not re-fetch and
|
||||
//! re-decrypt what it already resolved. That cache defaults to memory, which
|
||||
//! means every daemon restart pays for the whole walk again. This backs it with
|
||||
//! SQLite so it survives one.
|
||||
//!
|
||||
//! **Its own database file, not `cache.db`.** The daemon's `Db` is a single
|
||||
//! `Mutex<Connection>` shared by every FUSE thread and the control socket; SDK
|
||||
//! cache traffic is frequent, small, and entirely reconstructible, so putting it
|
||||
//! on that mutex would add contention to the daemon's hottest lock (and to a
|
||||
//! write-ahead log whose growth is already sensitive to how long that lock is
|
||||
//! held) for no durability benefit. A separate file also means no schema
|
||||
//! migration: this store can be deleted and rebuilt at any time.
|
||||
//!
|
||||
//! **What it holds.** Decrypted metadata, never key material — the SDK keeps
|
||||
//! node keys and hash keys in a separate in-memory secret cache that nothing
|
||||
//! here can reach. The same names and sizes are already persisted in `cache.db`
|
||||
//! (`nodes`), so this file widens no exposure that the state directory did not
|
||||
//! already have; `AppDirs::ensure` is what keeps that directory private.
|
||||
//!
|
||||
//! **Staleness.** A persisted cache outlives the process, so an entry can
|
||||
//! describe a node another client has since changed. The daemon's event loop is
|
||||
//! what closes that: it resumes from a persisted cursor and calls the SDK's
|
||||
//! `invalidate_caches_for_event` for every event it replays, including the ones
|
||||
//! that happened while the daemon was down. The one case that leaves no trail is
|
||||
//! a *seeded* cursor — a first-ever mount, or a cursor the daemon lost — where
|
||||
//! nothing says what changed, so the caller [`clear`](SdkCache::clear)s this
|
||||
//! store instead of trusting it.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use parking_lot::Mutex;
|
||||
use proton_sdk::cache::CacheRepository;
|
||||
use proton_sdk::error::{ProtonError, Result as SdkResult};
|
||||
use rusqlite::{Connection, params};
|
||||
|
||||
use crate::config::AppDirs;
|
||||
use crate::error::Result;
|
||||
|
||||
/// Process-wide handle, so the daemon's event loop can clear the same store the
|
||||
/// client is writing through without threading it down every call.
|
||||
static SHARED: OnceLock<Arc<SdkCache>> = OnceLock::new();
|
||||
|
||||
/// SQLite-backed entity cache for the SDK.
|
||||
pub struct SdkCache {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl SdkCache {
|
||||
/// Open (creating if needed) the entity cache under the app's state
|
||||
/// directory. The first call wins; later calls return the same handle, which
|
||||
/// is what lets the event loop clear the store the Drive client is using.
|
||||
pub fn shared(dirs: &AppDirs) -> Result<Arc<Self>> {
|
||||
if let Some(existing) = SHARED.get() {
|
||||
return Ok(existing.clone());
|
||||
}
|
||||
let cache = Arc::new(Self::open(&dirs.state_dir().join("sdk_cache.db"))?);
|
||||
Ok(SHARED.get_or_init(|| cache).clone())
|
||||
}
|
||||
|
||||
/// The already-open shared cache, if one was opened in this process.
|
||||
pub fn opened() -> Option<Arc<Self>> {
|
||||
SHARED.get().cloned()
|
||||
}
|
||||
|
||||
/// Open the store at `path`, creating its schema.
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
// Tags cascade with their entry, so a `set` that replaces a value cannot
|
||||
// leave the old value's tags behind pointing at the new one.
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS entry (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tag (
|
||||
key TEXT NOT NULL REFERENCES entry(key) ON DELETE CASCADE,
|
||||
tag TEXT NOT NULL,
|
||||
PRIMARY KEY (key, tag)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tag_tag ON tag(tag);",
|
||||
)?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
/// Drop every entry. Used when the daemon cannot prove the cache is current
|
||||
/// — a seeded event cursor — and on logout.
|
||||
pub fn clear_now(&self) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute_batch("DELETE FROM tag; DELETE FROM entry;")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_now(&self, key: &str, value: &str, tags: &[String]) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"INSERT INTO entry (key, value) VALUES (?1, ?2)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
params![key, value],
|
||||
)?;
|
||||
tx.execute("DELETE FROM tag WHERE key = ?1", params![key])?;
|
||||
{
|
||||
let mut stmt = tx.prepare("INSERT INTO tag (key, tag) VALUES (?1, ?2)")?;
|
||||
for tag in tags {
|
||||
stmt.execute(params![key, tag])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_now(&self, key: &str) -> Result<Option<String>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare("SELECT value FROM entry WHERE key = ?1")?;
|
||||
let mut rows = stmt.query(params![key])?;
|
||||
match rows.next()? {
|
||||
Some(row) => Ok(Some(row.get(0)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_now(&self, key: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute("DELETE FROM entry WHERE key = ?1", params![key])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_by_tag_now(&self, tag: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"DELETE FROM entry WHERE key IN (SELECT key FROM tag WHERE tag = ?1)",
|
||||
params![tag],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Entries carrying **all** of `tags` — the set intersection the SDK's
|
||||
/// `get_by_tags` contract specifies, not a union.
|
||||
fn get_by_tags_now(&self, tags: &[String]) -> Result<Vec<(String, String)>> {
|
||||
if tags.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.conn.lock();
|
||||
let placeholders = vec!["?"; tags.len()].join(",");
|
||||
let sql = format!(
|
||||
"SELECT e.key, e.value FROM entry e
|
||||
JOIN tag t ON t.key = e.key
|
||||
WHERE t.tag IN ({placeholders})
|
||||
GROUP BY e.key
|
||||
HAVING COUNT(DISTINCT t.tag) = {}",
|
||||
tags.len()
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(tags), |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
out.push(row?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// A cache failure is never fatal to the operation that triggered it — the SDK
|
||||
/// treats this repository as best-effort — but it still has to come back as the
|
||||
/// SDK's error type.
|
||||
fn sdk_error(e: crate::error::Error) -> ProtonError {
|
||||
ProtonError::invalid_operation(format!("sdk entity cache: {e}"))
|
||||
}
|
||||
|
||||
// Every method is a local SQLite statement on its own connection: microseconds,
|
||||
// no network, no shared lock with the daemon's database. Running them inline on
|
||||
// the caller's task is cheaper than the thread hop that moving them off would
|
||||
// cost.
|
||||
#[async_trait]
|
||||
impl CacheRepository for SdkCache {
|
||||
async fn set(&self, key: &str, value: &str, tags: &[String]) -> SdkResult<()> {
|
||||
self.set_now(key, value, tags).map_err(sdk_error)
|
||||
}
|
||||
|
||||
async fn get(&self, key: &str) -> SdkResult<Option<String>> {
|
||||
self.get_now(key).map_err(sdk_error)
|
||||
}
|
||||
|
||||
async fn remove(&self, key: &str) -> SdkResult<()> {
|
||||
self.remove_now(key).map_err(sdk_error)
|
||||
}
|
||||
|
||||
async fn remove_by_tag(&self, tag: &str) -> SdkResult<()> {
|
||||
self.remove_by_tag_now(tag).map_err(sdk_error)
|
||||
}
|
||||
|
||||
async fn clear(&self) -> SdkResult<()> {
|
||||
self.clear_now().map_err(sdk_error)
|
||||
}
|
||||
|
||||
async fn get_by_tags(&self, tags: &[String]) -> SdkResult<Vec<(String, String)>> {
|
||||
self.get_by_tags_now(tags).map_err(sdk_error)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cache() -> SdkCache {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE entry (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
CREATE TABLE tag (
|
||||
key TEXT NOT NULL REFERENCES entry(key) ON DELETE CASCADE,
|
||||
tag TEXT NOT NULL,
|
||||
PRIMARY KEY (key, tag)
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
SdkCache {
|
||||
conn: Mutex::new(conn),
|
||||
}
|
||||
}
|
||||
|
||||
fn tags(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|t| t.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_replaces_the_value_and_its_tags() {
|
||||
let cache = cache();
|
||||
cache.set_now("k", "one", &tags(&["a", "b"])).unwrap();
|
||||
cache.set_now("k", "two", &tags(&["b"])).unwrap();
|
||||
|
||||
assert_eq!(cache.get_now("k").unwrap().as_deref(), Some("two"));
|
||||
// The tag dropped by the second write must not still select the entry —
|
||||
// that is how a stale invalidation would miss it.
|
||||
assert!(cache.get_by_tags_now(&tags(&["a"])).unwrap().is_empty());
|
||||
assert_eq!(cache.get_by_tags_now(&tags(&["b"])).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_by_tags_intersects_rather_than_unions() {
|
||||
let cache = cache();
|
||||
cache.set_now("both", "1", &tags(&["x", "y"])).unwrap();
|
||||
cache.set_now("one", "2", &tags(&["x"])).unwrap();
|
||||
|
||||
let hits = cache.get_by_tags_now(&tags(&["x", "y"])).unwrap();
|
||||
assert_eq!(hits, vec![("both".to_string(), "1".to_string())]);
|
||||
// An empty tag list selects nothing, matching the SDK contract.
|
||||
assert!(cache.get_by_tags_now(&[]).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_by_tag_takes_the_entry_and_its_tags() {
|
||||
let cache = cache();
|
||||
cache.set_now("k", "v", &tags(&["gone"])).unwrap();
|
||||
cache.remove_by_tag_now("gone").unwrap();
|
||||
|
||||
assert!(cache.get_now("k").unwrap().is_none());
|
||||
assert!(cache.get_by_tags_now(&tags(&["gone"])).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_empties_the_store() {
|
||||
let cache = cache();
|
||||
cache.set_now("a", "1", &tags(&["t"])).unwrap();
|
||||
cache.set_now("b", "2", &[]).unwrap();
|
||||
cache.clear_now().unwrap();
|
||||
|
||||
assert!(cache.get_now("a").unwrap().is_none());
|
||||
assert!(cache.get_now("b").unwrap().is_none());
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ description = "FUSE on-demand filesystem for Proton Drive"
|
||||
[dependencies]
|
||||
pdfs-core.workspace = true
|
||||
proton-drive-rs.workspace = true
|
||||
futures.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
fuser.workspace = true
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Photo albums: the album listing, and one album's contents.
|
||||
//!
|
||||
//! Both are persisted projections of what the photos volume reports, refreshed
|
||||
//! on the same stale-while-serving rule as the timeline
|
||||
//! ([`crate::photos`]): a listing already on disk is served immediately and
|
||||
//! refreshed behind the caller, and only an empty one waits on the network.
|
||||
//!
|
||||
//! An album is a folder node carrying album properties, so it has a name like
|
||||
//! any other node; the properties supply the photo count and the cover. Albums
|
||||
//! other people share with us come from a second listing and live on *their*
|
||||
//! photos volume — which is why an album's photos are persisted per album rather
|
||||
//! than looked up in our own timeline, where a shared album's photos never
|
||||
//! appear.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use pdfs_core::control::{AlbumInfo, PhotoItem};
|
||||
use pdfs_core::db::StoredAlbum;
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
|
||||
use proton_drive_rs::NodeKind;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use tracing::warn;
|
||||
|
||||
use super::{
|
||||
ALBUM_SYNCED_PREFIX, ALBUMS_SYNCED_MS, Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS,
|
||||
TIMELINE_ENRICH_CHUNK, TIMELINE_TTL, now_ms, parse_uid,
|
||||
};
|
||||
|
||||
impl Core {
|
||||
/// The album listing, served from disk and refreshed behind the caller when
|
||||
/// stale. `None` means the account has no photos volume — the same signal
|
||||
/// [`Core::photos_timeline`] gives.
|
||||
pub(crate) fn albums(&self) -> CoreResult<Option<Vec<AlbumInfo>>> {
|
||||
let count = self.db.albums_count().map_err(CoreError::from)?;
|
||||
if count == 0 {
|
||||
// Nothing on disk, so this one request waits for the fetch — unless
|
||||
// the account is already known to have no photos volume.
|
||||
let known_empty = self.db.state_i64(PHOTOS_AVAILABLE).ok().flatten() == Some(0);
|
||||
if known_empty && !self.listing_stale(ALBUMS_SYNCED_MS, TIMELINE_TTL) {
|
||||
return Ok(None);
|
||||
}
|
||||
if !self.rt.block_on(self.refresh_albums())? {
|
||||
return Ok(None);
|
||||
}
|
||||
} else if self.listing_stale(ALBUMS_SYNCED_MS, TIMELINE_TTL) {
|
||||
self.spawn_albums_refresh();
|
||||
}
|
||||
|
||||
let albums = self.db.albums_list().map_err(CoreError::from)?;
|
||||
Ok(Some(albums.into_iter().map(album_info).collect()))
|
||||
}
|
||||
|
||||
/// Re-enumerate the albums — ours and the ones shared with us — and replace
|
||||
/// the persisted listing. Returns false when the account has no photos
|
||||
/// volume.
|
||||
pub(crate) async fn refresh_albums(&self) -> CoreResult<bool> {
|
||||
let photos = self.photos();
|
||||
if photos
|
||||
.get_photos_root()
|
||||
.await
|
||||
.map_err(|e| CoreError::from_api(&e, "photos root"))?
|
||||
.is_none()
|
||||
{
|
||||
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 0);
|
||||
let _ = self.db.set_state_i64(PHOTOS_SYNCED_MS, now_ms());
|
||||
let _ = self.db.set_state_i64(ALBUMS_SYNCED_MS, now_ms());
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut uids = photos
|
||||
.enumerate_album_node_uids()
|
||||
.await
|
||||
.map_err(|e| CoreError::from_api(&e, "albums"))?;
|
||||
|
||||
// Shared-with-me albums are a separate listing on the sharer's volume.
|
||||
// Best-effort: an account whose sharing listing fails still gets its own
|
||||
// albums rather than an error page.
|
||||
let shared: HashSet<String> = match photos.enumerate_shared_with_me_album_uids().await {
|
||||
Ok(shared) => {
|
||||
let keys = shared.iter().map(|u| u.to_string()).collect();
|
||||
uids.extend(shared);
|
||||
keys
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "enumerating shared-with-me albums failed");
|
||||
HashSet::new()
|
||||
}
|
||||
};
|
||||
|
||||
// The listings carry uids only; the name, photo count and cover come from
|
||||
// the album nodes themselves. A chunk that fails to resolve drops those
|
||||
// albums from this refresh rather than failing the whole listing.
|
||||
let mut albums: Vec<StoredAlbum> = Vec::with_capacity(uids.len());
|
||||
for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) {
|
||||
match photos.enumerate_nodes(chunk).await {
|
||||
Ok(nodes) => {
|
||||
for node in nodes {
|
||||
let key = node.uid.to_string();
|
||||
let props = node.album.as_ref();
|
||||
albums.push(StoredAlbum {
|
||||
shared: shared.contains(&key),
|
||||
uid: key,
|
||||
name: node.name,
|
||||
photo_count: props.map_or(0, |a| a.photo_count.max(0) as usize),
|
||||
cover_uid: props
|
||||
.and_then(|a| a.cover_photo_uid.as_ref())
|
||||
.map(|u| u.to_string()),
|
||||
last_activity: props.and_then(|a| a.last_activity_time),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(error = %e, "resolving an album chunk failed"),
|
||||
}
|
||||
}
|
||||
|
||||
// Newest activity first, and albums the server gave no activity time for
|
||||
// last — an album nobody has touched belongs below the ones in use. Ties
|
||||
// fall back to the name so the order is stable between refreshes.
|
||||
albums.sort_by(|a, b| {
|
||||
b.last_activity
|
||||
.cmp(&a.last_activity)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
|
||||
self.db.albums_replace(&albums).map_err(CoreError::from)?;
|
||||
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 1);
|
||||
let _ = self.db.set_state_i64(ALBUMS_SYNCED_MS, now_ms());
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Refresh the album listing off the request path. At most one runs at a time.
|
||||
pub(crate) fn spawn_albums_refresh(&self) {
|
||||
if self.albums_refreshing.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let core = self.clone();
|
||||
self.rt.spawn(async move {
|
||||
if let Err(e) = core.refresh_albums().await {
|
||||
warn!(error = %e, "background album refresh failed");
|
||||
}
|
||||
core.albums_refreshing.store(false, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
|
||||
/// A page of one album's photos, newest capture first. The album's contents
|
||||
/// are fetched on first open and refreshed behind the caller afterwards, so
|
||||
/// paging never waits on the network twice.
|
||||
pub(crate) fn album_photos(
|
||||
&self,
|
||||
album: &NodeUid,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
) -> CoreResult<Vec<PhotoItem>> {
|
||||
let key = album.to_string();
|
||||
let stamp = format!("{ALBUM_SYNCED_PREFIX}{key}");
|
||||
let count = self.db.album_photos_count(&key).map_err(CoreError::from)?;
|
||||
if count == 0 {
|
||||
self.rt.block_on(self.refresh_album(album))?;
|
||||
} else if self.listing_stale(&stamp, TIMELINE_TTL) {
|
||||
let core = self.clone();
|
||||
let album = album.clone();
|
||||
self.rt.spawn(async move {
|
||||
if let Err(e) = core.refresh_album(&album).await {
|
||||
warn!(%album, error = %e, "background album refresh failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let page = self
|
||||
.db
|
||||
.album_photos_page(&key, offset, limit)
|
||||
.map_err(CoreError::from)?;
|
||||
Ok(page.into_iter().map(|p| self.photo_item(p)).collect())
|
||||
}
|
||||
|
||||
/// Re-enumerate one album's photos and replace its persisted contents.
|
||||
pub(crate) async fn refresh_album(&self, album: &NodeUid) -> CoreResult<()> {
|
||||
let photos = self.photos();
|
||||
let items = photos
|
||||
.enumerate_album(album)
|
||||
.await
|
||||
.map_err(|e| CoreError::from_api(&e, "album"))?;
|
||||
|
||||
// Same enrichment as the timeline: the listing carries a uid and a
|
||||
// capture time, and the gallery needs a name and media type to classify
|
||||
// each entry. Best-effort per chunk.
|
||||
let uids: Vec<NodeUid> = items.iter().map(|it| it.uid.clone()).collect();
|
||||
let mut meta: HashMap<String, (Option<String>, Option<String>)> = HashMap::new();
|
||||
for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) {
|
||||
match photos.enumerate_nodes(chunk).await {
|
||||
Ok(nodes) => {
|
||||
for node in nodes {
|
||||
let media_type = match &node.kind {
|
||||
NodeKind::File { media_type, .. } => Some(media_type.clone()),
|
||||
NodeKind::Folder => None,
|
||||
};
|
||||
meta.insert(node.uid.to_string(), (Some(node.name), media_type));
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(error = %e, "resolving album photo metadata failed"),
|
||||
}
|
||||
}
|
||||
|
||||
let rows: Vec<(String, i64, Option<String>, Option<String>)> = items
|
||||
.iter()
|
||||
.map(|it| {
|
||||
let key = it.uid.to_string();
|
||||
let (name, media_type) = meta.get(&key).cloned().unwrap_or((None, None));
|
||||
(key, it.capture_time, name, media_type)
|
||||
})
|
||||
.collect();
|
||||
let key = album.to_string();
|
||||
self.db
|
||||
.album_photos_replace(&key, &rows)
|
||||
.map_err(CoreError::from)?;
|
||||
let _ = self
|
||||
.db
|
||||
.set_state_i64(&format!("{ALBUM_SYNCED_PREFIX}{key}"), now_ms());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop the album listing's and every album's freshness stamp, so the next
|
||||
/// request re-enumerates. Paired with [`Core::invalidate_photos`] — a change
|
||||
/// to the timeline can just as easily be a change to an album.
|
||||
pub(crate) fn invalidate_albums(&self) {
|
||||
let _ = self.db.clear_state(ALBUMS_SYNCED_MS);
|
||||
let _ = self.db.clear_state_prefix(ALBUM_SYNCED_PREFIX);
|
||||
}
|
||||
}
|
||||
|
||||
/// Project a persisted album into the wire item a front-end paints.
|
||||
fn album_info(album: StoredAlbum) -> AlbumInfo {
|
||||
AlbumInfo {
|
||||
uid: album.uid,
|
||||
name: album.name,
|
||||
photo_count: album.photo_count,
|
||||
// A cover uid the daemon cannot parse is no cover: the front-end would
|
||||
// only ask for a thumbnail that can never be resolved.
|
||||
cover_uid: album.cover_uid.filter(|uid| parse_uid(uid).is_some()),
|
||||
last_activity: album.last_activity,
|
||||
shared: album.shared,
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,83 @@ fn forget_and_notify(st: &mut State, notifier: Option<&Notifier>, uid: &NodeUid)
|
||||
true
|
||||
}
|
||||
|
||||
fn invalidate_access_changes(notifier: Option<&Notifier>, changed: &[u64]) {
|
||||
let Some(notifier) = notifier else {
|
||||
return;
|
||||
};
|
||||
for &ino in changed {
|
||||
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
fn hide_foreign_deleted_and_notify(st: &mut State, notifier: Option<&Notifier>, uid: &NodeUid) {
|
||||
let child = st.by_uid.get(uid).copied();
|
||||
let dentry = child.and_then(|ino| {
|
||||
st.entries
|
||||
.get(&ino)
|
||||
.map(|entry| (entry.parent, entry.node.name.clone()))
|
||||
});
|
||||
let changed = st.downgrade_shared_subtree(uid);
|
||||
st.hide_foreign_subtree(uid);
|
||||
invalidate_access_changes(notifier, &changed);
|
||||
if let (Some(notifier), Some((parent, name)), Some(child)) = (notifier, dentry, child) {
|
||||
let _ = notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the fail-closed part of an access event and install the same denial
|
||||
/// in resident state even when SQLite rejects the write. The caller must not
|
||||
/// acknowledge the event when this returns an error.
|
||||
fn apply_access_downgrade(
|
||||
db: &Db,
|
||||
event: &DriveEvent,
|
||||
mut deny: impl FnMut(Option<&NodeUid>),
|
||||
) -> pdfs_core::Result<()> {
|
||||
match event {
|
||||
DriveEvent::NodeDeleted { node_uid, .. } => {
|
||||
let result = db.set_share_access(node_uid, Access::Viewer);
|
||||
deny(Some(node_uid));
|
||||
result
|
||||
}
|
||||
DriveEvent::ContinuityLost { .. }
|
||||
| DriveEvent::ScopeAccessLost { .. }
|
||||
| DriveEvent::SharedWithMeUpdated { .. } => {
|
||||
let result = db.downgrade_all_share_access().map(|_| ());
|
||||
deny(None);
|
||||
result
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_foreign_delete(
|
||||
db: &Db,
|
||||
uid: &NodeUid,
|
||||
mut deny_and_hide: impl FnMut(&NodeUid),
|
||||
) -> pdfs_core::Result<()> {
|
||||
let result = db.tombstone_foreign_subtree(uid);
|
||||
deny_and_hide(uid);
|
||||
result
|
||||
}
|
||||
|
||||
fn is_foreign_node_delete(event: &DriveEvent, own_volume: &VolumeId) -> bool {
|
||||
match event {
|
||||
DriveEvent::NodeDeleted { node_uid, .. } => {
|
||||
!is_own_or_virtual_uid(node_uid, own_volume) && !is_local_uid(node_uid)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn event_serializes_shared_publication(event: &DriveEvent, own_volume: &VolumeId) -> bool {
|
||||
matches!(
|
||||
event,
|
||||
DriveEvent::ContinuityLost { .. }
|
||||
| DriveEvent::ScopeAccessLost { .. }
|
||||
| DriveEvent::SharedWithMeUpdated { .. }
|
||||
) || is_foreign_node_delete(event, own_volume)
|
||||
}
|
||||
|
||||
/// Apply one remote event to the local cache and notify the kernel so it drops
|
||||
/// any stale cached metadata/data for the affected inodes.
|
||||
///
|
||||
@@ -43,7 +120,33 @@ fn forget_and_notify(st: &mut State, notifier: Option<&Notifier>, uid: &NodeUid)
|
||||
/// read-side twin of `docs/BUGS.md` B74. Inode numbers are per-mount, so each
|
||||
/// mount must be notified through its **own** channel; that pairing is what
|
||||
/// [`Core::for_each_mount`] exists to preserve.
|
||||
fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) {
|
||||
fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdfs_core::Result<()> {
|
||||
let foreign_delete = is_foreign_node_delete(event, &core.primary_root_uid.volume_id);
|
||||
let serializes_shared_publication =
|
||||
event_serializes_shared_publication(event, &core.primary_root_uid.volume_id);
|
||||
let _publication = serializes_shared_publication.then(|| core.shared_publication.lock());
|
||||
if serializes_shared_publication {
|
||||
core.invalidate_shared_refreshes();
|
||||
}
|
||||
let mut apply_result = if let DriveEvent::NodeDeleted { node_uid, .. } = event
|
||||
&& foreign_delete
|
||||
{
|
||||
apply_foreign_delete(&core.db, node_uid, |uid| {
|
||||
core.for_each_mount(|st, notifier| {
|
||||
hide_foreign_deleted_and_notify(st, notifier, uid);
|
||||
});
|
||||
})
|
||||
} else {
|
||||
apply_access_downgrade(&core.db, event, |root| {
|
||||
core.for_each_mount(|st, notifier| {
|
||||
let changed = match root {
|
||||
Some(uid) => st.downgrade_shared_subtree(uid),
|
||||
None => st.downgrade_known_shared_access(),
|
||||
};
|
||||
invalidate_access_changes(notifier, &changed);
|
||||
});
|
||||
})
|
||||
};
|
||||
match event {
|
||||
DriveEvent::NodeUpdated {
|
||||
node_uid,
|
||||
@@ -105,15 +208,21 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) {
|
||||
}
|
||||
DriveEvent::NodeDeleted { node_uid, .. } => {
|
||||
core.cache.evict(node_uid);
|
||||
core.for_each_mount(|st, notifier| {
|
||||
forget_and_notify(st, notifier, node_uid);
|
||||
});
|
||||
if !foreign_delete {
|
||||
core.for_each_mount(|st, notifier| {
|
||||
forget_and_notify(st, notifier, node_uid);
|
||||
});
|
||||
}
|
||||
}
|
||||
// Continuity or scope was lost: our cached listings may be arbitrarily
|
||||
// stale, so drop every listing and tell the kernel to forget all
|
||||
// metadata. Inodes stay stable; dirs simply re-enumerate on next access.
|
||||
DriveEvent::ContinuityLost { .. } | DriveEvent::ScopeAccessLost { .. } => {
|
||||
warn!("event continuity lost; dropping all cached listings, resyncing lazily");
|
||||
// Losing event continuity or any shared-access signal makes persisted
|
||||
// shared roots untrustworthy. Keep owned/device trees unchanged, but
|
||||
// fail every known shared subtree closed until a root refresh carries a
|
||||
// current membership role.
|
||||
DriveEvent::ContinuityLost { .. }
|
||||
| DriveEvent::ScopeAccessLost { .. }
|
||||
| DriveEvent::SharedWithMeUpdated { .. } => {
|
||||
warn!("event access continuity lost; downgrading shared trees and resyncing lazily");
|
||||
apply_result = apply_result.and(core.db.clear_state(SHARED_WITH_ME_SYNCED_MS));
|
||||
core.for_each_mount(|st, notifier| {
|
||||
let dirs: Vec<u64> = st.children.keys().copied().collect();
|
||||
for &ino in &dirs {
|
||||
@@ -126,8 +235,20 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) {
|
||||
}
|
||||
// No substantive local change; the cursor advance is handled by the
|
||||
// caller persisting the event id.
|
||||
DriveEvent::CursorAdvanced { .. } | DriveEvent::SharedWithMeUpdated { .. } => {}
|
||||
DriveEvent::CursorAdvanced { .. } => {}
|
||||
}
|
||||
apply_result
|
||||
}
|
||||
|
||||
fn acknowledge_applied_event(
|
||||
db: &Db,
|
||||
event: &DriveEvent,
|
||||
applied: pdfs_core::Result<()>,
|
||||
) -> pdfs_core::Result<DriveEventId> {
|
||||
applied?;
|
||||
let id = event.id().clone();
|
||||
db.set_event_cursor(id.as_str())?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// The folders one batch of events changed, collected so the listings are
|
||||
@@ -237,6 +358,14 @@ pub(super) async fn run_event_sync(
|
||||
// started offline (offline.md Phase 1) — so retry rather than giving up,
|
||||
// which used to disable live sync for the life of the daemon.
|
||||
Ok(None) => {
|
||||
// Nothing says what changed before this cursor, so a persisted SDK
|
||||
// entity cache from an earlier run cannot be trusted: drop it rather
|
||||
// than serve metadata no event will ever invalidate.
|
||||
if let Some(cache) = pdfs_core::sdkcache::SdkCache::opened()
|
||||
&& let Err(e) = cache.clear_now()
|
||||
{
|
||||
warn!(error = %e, "clearing the persisted SDK entity cache failed");
|
||||
}
|
||||
let mut delay = ONLINE_PROBE_MIN;
|
||||
loop {
|
||||
match client.enumerate_events(&scope, None).await {
|
||||
@@ -296,12 +425,17 @@ pub(super) async fn run_event_sync(
|
||||
warn!(error = %e, "sdk cache invalidation for event failed; retaining cursor for retry");
|
||||
break;
|
||||
}
|
||||
apply_event(&core, event, &mut dirty);
|
||||
let applied = event.id().clone();
|
||||
if let Err(e) = db.set_event_cursor(applied.as_str()) {
|
||||
warn!(error = %e, "persist event cursor failed; retaining prior cursor for retry");
|
||||
break;
|
||||
}
|
||||
let applied = match acknowledge_applied_event(
|
||||
&db,
|
||||
event,
|
||||
apply_event(&core, event, &mut dirty),
|
||||
) {
|
||||
Ok(applied) => applied,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "event application or acknowledgment failed; retaining prior cursor for retry");
|
||||
break;
|
||||
}
|
||||
};
|
||||
// Advance only after this exact event is durably acknowledged. If a
|
||||
// later event fails, polling resumes from here and replays nothing
|
||||
// that was not already made visible locally.
|
||||
@@ -396,3 +530,276 @@ fn scan_local_once(
|
||||
}
|
||||
indexing.store(false, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::Cell;
|
||||
|
||||
fn uid(link: &str) -> NodeUid {
|
||||
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
|
||||
}
|
||||
|
||||
fn folder(volume: &str, link: &str, parent: Option<NodeUid>, name: &str) -> Node {
|
||||
Node {
|
||||
uid: NodeUid::new(VolumeId::from(volume), LinkId::from(link)),
|
||||
parent_uid: parent,
|
||||
kind: NodeKind::Folder,
|
||||
name: name.to_string(),
|
||||
creation_time: 1,
|
||||
modification_time: 1,
|
||||
trashed: false,
|
||||
is_shared: false,
|
||||
is_shared_publicly: false,
|
||||
signature_email: None,
|
||||
membership: None,
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_delete_invalidates_an_earlier_refresh_generation() {
|
||||
let own_volume = VolumeId::from("own");
|
||||
let event = DriveEvent::NodeDeleted {
|
||||
id: DriveEventId::from("foreign-delete"),
|
||||
node_uid: NodeUid::new(VolumeId::from("foreign"), LinkId::from("child")),
|
||||
parent_node_uid: None,
|
||||
};
|
||||
let generation = AtomicU64::new(7);
|
||||
let captured = generation.load(Ordering::SeqCst);
|
||||
|
||||
assert!(event_serializes_shared_publication(&event, &own_volume));
|
||||
generation.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
assert!(!refresh_generation_is_current(
|
||||
captured,
|
||||
generation.load(Ordering::SeqCst)
|
||||
));
|
||||
let own_delete = DriveEvent::NodeDeleted {
|
||||
id: DriveEventId::from("own-delete"),
|
||||
node_uid: NodeUid::new(own_volume.clone(), LinkId::from("child")),
|
||||
parent_node_uid: None,
|
||||
};
|
||||
assert!(!event_serializes_shared_publication(
|
||||
&own_delete,
|
||||
&own_volume
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_delete_tombstones_subtree_and_advances_cursor_after_success() {
|
||||
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
|
||||
let root = folder("own", "root", None, "My Files");
|
||||
let deleted = folder(
|
||||
"foreign",
|
||||
"deleted",
|
||||
Some(root.uid.clone()),
|
||||
"Deleted foreign",
|
||||
);
|
||||
let deep = folder(
|
||||
"foreign",
|
||||
"deep",
|
||||
Some(deleted.uid.clone()),
|
||||
"Searchable descendant",
|
||||
);
|
||||
db.upsert_nodes(&[root, deleted.clone(), deep.clone()])
|
||||
.unwrap();
|
||||
db.set_share_access(&deleted.uid, Access::Editor).unwrap();
|
||||
db.enqueue_op(&PendingOp {
|
||||
id: 0,
|
||||
kind: OP_RENAME.to_string(),
|
||||
uid: deep.uid.to_string(),
|
||||
parent_uid: Some(deleted.uid.to_string()),
|
||||
name: Some("pending".to_string()),
|
||||
blob_path: None,
|
||||
meta_json: Some("{}".to_string()),
|
||||
created_at: 1,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
})
|
||||
.unwrap();
|
||||
let event = DriveEvent::NodeDeleted {
|
||||
id: DriveEventId::from("foreign-delete-success"),
|
||||
node_uid: deleted.uid.clone(),
|
||||
parent_node_uid: deleted.parent_uid.clone(),
|
||||
};
|
||||
let denied = Cell::new(false);
|
||||
|
||||
let applied = apply_foreign_delete(&db, &deleted.uid, |_| denied.set(true));
|
||||
let acknowledged = acknowledge_applied_event(&db, &event, applied).unwrap();
|
||||
|
||||
assert!(denied.get());
|
||||
assert_eq!(acknowledged.as_str(), "foreign-delete-success");
|
||||
assert_eq!(
|
||||
db.get_event_cursor().unwrap().as_deref(),
|
||||
Some("foreign-delete-success")
|
||||
);
|
||||
for uid in [&deleted.uid, &deep.uid] {
|
||||
assert!(db.node_by_uid(&uid.to_string()).unwrap().unwrap().trashed);
|
||||
}
|
||||
assert!(db.search("Searchable", 10).unwrap().is_empty());
|
||||
assert_eq!(db.pending_ops().unwrap().len(), 1);
|
||||
assert_eq!(db.share_access(&deleted.uid).unwrap(), Some(Access::Viewer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_delete_db_failure_denies_memory_and_retains_prior_cursor() {
|
||||
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
|
||||
let root = folder("own", "root", None, "My Files");
|
||||
let deleted = folder(
|
||||
"foreign",
|
||||
"deleted",
|
||||
Some(root.uid.clone()),
|
||||
"Deleted foreign",
|
||||
);
|
||||
let deep = folder(
|
||||
"foreign",
|
||||
"deep",
|
||||
Some(deleted.uid.clone()),
|
||||
"Still searchable",
|
||||
);
|
||||
db.upsert_nodes(&[root, deleted.clone(), deep.clone()])
|
||||
.unwrap();
|
||||
db.set_share_access(&deleted.uid, Access::Editor).unwrap();
|
||||
db.set_event_cursor("prior-event").unwrap();
|
||||
db.with_conn(|conn| {
|
||||
conn.execute_batch(
|
||||
"CREATE TRIGGER reject_foreign_tombstone
|
||||
BEFORE UPDATE OF trashed ON nodes
|
||||
WHEN OLD.uid = 'foreign~deleted'
|
||||
BEGIN SELECT RAISE(ABORT, 'forced tombstone failure'); END;",
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let event = DriveEvent::NodeDeleted {
|
||||
id: DriveEventId::from("foreign-delete-failed"),
|
||||
node_uid: deleted.uid.clone(),
|
||||
parent_node_uid: deleted.parent_uid.clone(),
|
||||
};
|
||||
let denied = Cell::new(false);
|
||||
|
||||
let applied = apply_foreign_delete(&db, &deleted.uid, |_| denied.set(true));
|
||||
|
||||
assert!(applied.is_err());
|
||||
assert!(
|
||||
denied.get(),
|
||||
"resident state must fail closed on DB failure"
|
||||
);
|
||||
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
|
||||
assert_eq!(
|
||||
db.get_event_cursor().unwrap().as_deref(),
|
||||
Some("prior-event")
|
||||
);
|
||||
assert!(
|
||||
!db.node_by_uid(&deleted.uid.to_string())
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.trashed
|
||||
);
|
||||
assert_eq!(db.search("searchable", 10).unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
db.share_access(&deleted.uid).unwrap(),
|
||||
Some(Access::Editor),
|
||||
"the failed atomic transaction must not partially publish"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_share_downgrade_failure_denies_memory_and_retains_cursor() {
|
||||
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
|
||||
let root = uid("shared");
|
||||
db.set_share_access(&root, Access::Editor).unwrap();
|
||||
db.with_conn(|conn| {
|
||||
conn.execute_batch(
|
||||
"CREATE TRIGGER reject_share_update
|
||||
BEFORE UPDATE ON share_access
|
||||
BEGIN SELECT RAISE(FAIL, 'forced downgrade failure'); END;",
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let event = DriveEvent::NodeDeleted {
|
||||
id: DriveEventId::from("event-1"),
|
||||
node_uid: root,
|
||||
parent_node_uid: None,
|
||||
};
|
||||
let denied = Cell::new(false);
|
||||
|
||||
let applied = apply_access_downgrade(&db, &event, |root| {
|
||||
denied.set(root.is_some());
|
||||
});
|
||||
assert!(applied.is_err());
|
||||
assert!(denied.get(), "resident state must fail closed immediately");
|
||||
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
|
||||
assert!(
|
||||
db.get_event_cursor().unwrap().is_none(),
|
||||
"failed downgrade must leave the event unacknowledged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_resident_root_without_share_row_is_denied_and_tombstoned() {
|
||||
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
|
||||
let root = uid("resident-without-row");
|
||||
assert!(db.share_access(&root).unwrap().is_none());
|
||||
let resident = std::cell::RefCell::new(std::collections::HashMap::from([(
|
||||
root.clone(),
|
||||
Access::Editor,
|
||||
)]));
|
||||
let event = DriveEvent::NodeDeleted {
|
||||
id: DriveEventId::from("event-missing-row"),
|
||||
node_uid: root.clone(),
|
||||
parent_node_uid: None,
|
||||
};
|
||||
|
||||
let applied = apply_access_downgrade(&db, &event, |target| {
|
||||
assert_eq!(target, Some(&root));
|
||||
resident.borrow_mut().insert(root.clone(), Access::Viewer);
|
||||
});
|
||||
assert!(applied.is_ok());
|
||||
assert_eq!(resident.borrow().get(&root), Some(&Access::Viewer));
|
||||
assert_eq!(db.share_access(&root).unwrap(), Some(Access::Viewer));
|
||||
|
||||
let acknowledged = acknowledge_applied_event(&db, &event, applied).unwrap();
|
||||
assert_eq!(acknowledged.as_str(), "event-missing-row");
|
||||
assert_eq!(
|
||||
db.get_event_cursor().unwrap().as_deref(),
|
||||
Some("event-missing-row")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuity_downgrade_failure_denies_memory_and_retains_cursor() {
|
||||
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
|
||||
let root = uid("shared");
|
||||
db.set_share_access(&root, Access::Editor).unwrap();
|
||||
db.with_conn(|conn| {
|
||||
conn.execute_batch(
|
||||
"CREATE TRIGGER reject_all_share_updates
|
||||
BEFORE UPDATE ON share_access
|
||||
BEGIN SELECT RAISE(FAIL, 'forced global downgrade failure'); END;",
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let event = DriveEvent::ContinuityLost {
|
||||
id: DriveEventId::from("event-2"),
|
||||
};
|
||||
let denied_all = Cell::new(false);
|
||||
|
||||
let applied = apply_access_downgrade(&db, &event, |root| {
|
||||
denied_all.set(root.is_none());
|
||||
});
|
||||
assert!(applied.is_err());
|
||||
assert!(
|
||||
denied_all.get(),
|
||||
"all resident shared roots must fail closed immediately"
|
||||
);
|
||||
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
|
||||
assert!(db.get_event_cursor().unwrap().is_none());
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
}
|
||||
RefreshScope::Photos => {
|
||||
core.invalidate_photos();
|
||||
// Whatever changed the timeline can just as easily have
|
||||
// changed an album; one scope covers the whole photos view.
|
||||
core.invalidate_albums();
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
@@ -200,7 +203,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
limit,
|
||||
kind,
|
||||
range,
|
||||
}) => match core.photos_timeline(offset, limit, kind, range) {
|
||||
favorites,
|
||||
}) => match core.photos_timeline(offset, limit, kind, range, favorites) {
|
||||
Ok(Some(items)) => CtlResponse::Photos {
|
||||
available: true,
|
||||
items,
|
||||
@@ -213,6 +217,44 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::SetPhotoFavorite { uid, favorite }) => match parse_uid(&uid) {
|
||||
Some(node_uid) => match core.set_photo_favorite(&node_uid, favorite) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: if favorite {
|
||||
"added to favourites".to_string()
|
||||
} else {
|
||||
"removed from favourites".to_string()
|
||||
},
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
None => CtlResponse::error(CoreError::invalid(format!("invalid uid: {uid}"))),
|
||||
},
|
||||
Ok(CtlRequest::PhotoAlbums) => match core.albums() {
|
||||
Ok(Some(items)) => CtlResponse::Albums {
|
||||
available: true,
|
||||
items,
|
||||
},
|
||||
Ok(None) => CtlResponse::Albums {
|
||||
available: false,
|
||||
items: Vec::new(),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::AlbumPhotos { uid, offset, limit }) => match parse_uid(&uid) {
|
||||
// An album page is a timeline page as far as the front-end is
|
||||
// concerned, so it comes back in the same reply — with `counts`
|
||||
// unset, which describe the whole timeline rather than this album.
|
||||
Some(album) => match core.album_photos(&album, offset, limit) {
|
||||
Ok(items) => CtlResponse::Photos {
|
||||
available: true,
|
||||
items,
|
||||
counts: None,
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
||||
},
|
||||
Ok(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) {
|
||||
Ok(months) => CtlResponse::PhotoMonths {
|
||||
months: months
|
||||
@@ -561,6 +603,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Ok(items) => CtlResponse::Devices { items },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ListLocations) => match core.list_locations() {
|
||||
Ok(items) => CtlResponse::Locations { items },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::RenameDevice { uid, name }) => match core.rename_device(&uid, &name) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: format!("renamed device to {name}"),
|
||||
@@ -692,6 +738,28 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ShareNodeByUid {
|
||||
uid,
|
||||
emails,
|
||||
role,
|
||||
message,
|
||||
}) => match core.share_node_by_uid(&uid, &emails, &role, message.as_deref()) {
|
||||
Ok((proton, external)) => {
|
||||
core.log_activity(
|
||||
ActivityKind::Share,
|
||||
&uid,
|
||||
format!("{} recipient(s) as {role}", proton + external),
|
||||
true,
|
||||
);
|
||||
CtlResponse::Ok {
|
||||
message: format!("invited {proton} Proton and {external} external user(s)"),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
core.log_activity(ActivityKind::Share, &uid, &e, false);
|
||||
CtlResponse::error(e)
|
||||
}
|
||||
},
|
||||
Ok(CtlRequest::ListShare { path }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.list_share(&rel) {
|
||||
Ok((entries, link)) => CtlResponse::Share { entries, link },
|
||||
@@ -699,6 +767,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ListShareByUid { uid }) => match core.list_share_by_uid(&uid) {
|
||||
Ok((entries, link)) => CtlResponse::Share { entries, link },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::UpdateShareRole {
|
||||
path,
|
||||
id,
|
||||
@@ -713,6 +785,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::UpdateShareRoleByUid {
|
||||
uid,
|
||||
id,
|
||||
kind,
|
||||
role,
|
||||
}) => match core.update_share_role_by_uid(&uid, &id, kind, &role) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: format!("role updated to {role}"),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::RemoveShareEntry { path, id, kind }) => {
|
||||
match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.remove_share_entry(&rel, &id, kind) {
|
||||
@@ -727,6 +810,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::RemoveShareEntryByUid { uid, id, kind }) => {
|
||||
match core.remove_share_entry_by_uid(&uid, &id, kind) {
|
||||
Ok(()) => {
|
||||
core.log_activity(ActivityKind::Unshare, &uid, "access removed", true);
|
||||
CtlResponse::Ok {
|
||||
message: "removed".to_string(),
|
||||
}
|
||||
}
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::CreatePublicLink {
|
||||
path,
|
||||
role,
|
||||
@@ -745,6 +839,21 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::CreatePublicLinkByUid {
|
||||
uid,
|
||||
role,
|
||||
password,
|
||||
expires,
|
||||
}) => match core.create_public_link_by_uid(&uid, &role, password.as_deref(), expires) {
|
||||
Ok(link) => {
|
||||
core.log_activity(ActivityKind::PublicLink, &uid, "link created", true);
|
||||
CtlResponse::PublicLink { link }
|
||||
}
|
||||
Err(e) => {
|
||||
core.log_activity(ActivityKind::PublicLink, &uid, &e, false);
|
||||
CtlResponse::error(e)
|
||||
}
|
||||
},
|
||||
Ok(CtlRequest::RemovePublicLink { path, id }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.remove_public_link(&rel, &id) {
|
||||
Ok(()) => {
|
||||
@@ -757,6 +866,89 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::RemovePublicLinkByUid { uid, id }) => {
|
||||
match core.remove_public_link_by_uid(&uid, &id) {
|
||||
Ok(()) => {
|
||||
core.log_activity(ActivityKind::Unshare, &uid, "link removed", true);
|
||||
CtlResponse::Ok {
|
||||
message: "public link removed".to_string(),
|
||||
}
|
||||
}
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::ListRevisions { path }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.list_revisions(&rel) {
|
||||
Ok(items) => CtlResponse::Revisions { items },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ListRevisionsByUid { uid }) => match core.list_revisions_by_uid(&uid) {
|
||||
Ok(items) => CtlResponse::Revisions { items },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::RestoreRevision { path, revision_id }) => {
|
||||
match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.restore_revision(&rel, &revision_id) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: format!("restoring version {revision_id} of {path}"),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::RestoreRevisionByUid { uid, revision_id }) => {
|
||||
match core.restore_revision_by_uid(&uid, &revision_id) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: format!("restoring version {revision_id}"),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::DeleteRevision { path, revision_id }) => {
|
||||
match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.delete_revision(&rel, &revision_id) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: format!("deleted version {revision_id} of {path}"),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::DeleteRevisionByUid { uid, revision_id }) => {
|
||||
match core.delete_revision_by_uid(&uid, &revision_id) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: format!("deleted version {revision_id}"),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::SaveRevisionAs {
|
||||
path,
|
||||
revision_id,
|
||||
dest,
|
||||
}) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.save_revision_as(&rel, &revision_id, Path::new(&dest)) {
|
||||
Ok(written) => CtlResponse::Ok {
|
||||
message: format!("wrote {} to {dest}", human_bytes(written)),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::SaveRevisionAsByUid {
|
||||
uid,
|
||||
revision_id,
|
||||
dest,
|
||||
}) => match core.save_revision_as_by_uid(&uid, &revision_id, Path::new(&dest)) {
|
||||
Ok(written) => CtlResponse::Ok {
|
||||
message: format!("wrote {} to {dest}", human_bytes(written)),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ListSharedWithMe) => match core.list_shared_with_me() {
|
||||
Ok(entries) => CtlResponse::Entries { entries },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
|
||||
+437
-122
@@ -11,12 +11,14 @@ use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use fuser::{Config, MountOption, Session};
|
||||
use parking_lot::Mutex;
|
||||
use pdfs_core::batch;
|
||||
use pdfs_core::control::{
|
||||
ActivityKind, DeviceInfo, JobItem, SyncFolderInfo, SyncPhase, SyncProgress,
|
||||
ActivityKind, DeviceInfo, JobItem, MountKind, MountSpec, SyncFolderInfo, SyncPhase,
|
||||
SyncProgress,
|
||||
};
|
||||
use pdfs_core::db::{StoredDevice, StoredSyncFolder};
|
||||
use pdfs_core::mounts::MountMode;
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid};
|
||||
use proton_drive_rs::{DeviceType, Node};
|
||||
@@ -25,11 +27,69 @@ use tracing::{info, warn};
|
||||
|
||||
use super::sync::{self, base_name};
|
||||
use super::{
|
||||
BackgroundSession, Core, ProtonFs, State, SwitchBlocked, clear_stale_mount, device_type_str,
|
||||
dir_is_empty, evict_dir_contents, fuse_connection_id, is_stale_mount, now_secs, parse_uid,
|
||||
sync_folder_info, this_hostname, umount_session_unblocked,
|
||||
Core, SecondaryInsertRejection, SecondaryMount, State, SwitchBlocked, clear_stale_mount,
|
||||
device_type_str, dir_is_empty, evict_dir_contents, fuse_connection_id, is_stale_mount,
|
||||
now_secs, parse_uid, spawn_session, sync_folder_info, this_hostname,
|
||||
};
|
||||
|
||||
/// A mode that this daemon is allowed to apply.
|
||||
///
|
||||
/// Persisted and control-protocol values first pass through [`MountMode`] so
|
||||
/// forward-compatible unknown values never reach either destructive transition.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ApplicableMountMode {
|
||||
Mirror,
|
||||
OnDemand,
|
||||
}
|
||||
|
||||
impl ApplicableMountMode {
|
||||
fn parse(value: &str) -> Result<Self, String> {
|
||||
match MountMode::from(value) {
|
||||
MountMode::Mirror => Ok(Self::Mirror),
|
||||
MountMode::OnDemand => Ok(Self::OnDemand),
|
||||
MountMode::Unknown => Err(format!("unknown mode {value:?} (want mirror|ondemand)")),
|
||||
}
|
||||
}
|
||||
|
||||
const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Mirror => "mirror",
|
||||
Self::OnDemand => "ondemand",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn select_mode_transition(
|
||||
current: &str,
|
||||
target: &str,
|
||||
) -> Result<Option<ApplicableMountMode>, String> {
|
||||
let current = ApplicableMountMode::parse(current)
|
||||
.map_err(|error| format!("unsupported current mode: {error}"))?;
|
||||
let target = ApplicableMountMode::parse(target)
|
||||
.map_err(|error| format!("unsupported target mode: {error}"))?;
|
||||
Ok((current != target).then_some(target))
|
||||
}
|
||||
|
||||
fn restore_snapshot_matches(snapshot: &StoredSyncFolder, current: &StoredSyncFolder) -> bool {
|
||||
current.id == snapshot.id
|
||||
&& current.local_path == snapshot.local_path
|
||||
&& current.remote_uid == snapshot.remote_uid
|
||||
&& current.remote_share_id == snapshot.remote_share_id
|
||||
&& current.mode == "ondemand"
|
||||
}
|
||||
|
||||
fn pending_intent_matches(pending: Option<&str>, expected: Option<&str>) -> bool {
|
||||
expected.is_none_or(|expected| pending == Some(expected))
|
||||
}
|
||||
|
||||
fn commit_after_teardown<T, E>(
|
||||
teardown: Result<(), E>,
|
||||
commit: impl FnOnce() -> T,
|
||||
) -> Result<T, E> {
|
||||
teardown?;
|
||||
Ok(commit())
|
||||
}
|
||||
|
||||
/// The device uid this machine has been adopted into, from `config.json`.
|
||||
///
|
||||
/// Read fresh each time rather than cached on [`Core`]: adoption rewrites the
|
||||
@@ -57,6 +117,22 @@ pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> {
|
||||
impl Core {
|
||||
// ---- devices ----------------------------------------------------------
|
||||
|
||||
/// Unified local-location listing for the control protocol.
|
||||
pub(crate) fn list_locations(&self) -> CoreResult<Vec<MountSpec>> {
|
||||
let progress = self.sync_progress.lock();
|
||||
let mut locations = self
|
||||
.db
|
||||
.mount_list()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
for location in &mut locations {
|
||||
location.mounted = self.states.is_mounted_at(Path::new(&location.local_path));
|
||||
if let MountKind::Device { sync_folder_id } = &location.kind {
|
||||
location.progress = progress.get(sync_folder_id).cloned();
|
||||
}
|
||||
}
|
||||
Ok(locations)
|
||||
}
|
||||
|
||||
/// List the account's registered devices, flagging the one *this* machine
|
||||
/// syncs to so a front-end can treat it as more than another computer in the
|
||||
/// list — deleting it takes this machine's synced folders down with it.
|
||||
@@ -308,6 +384,15 @@ impl Core {
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
// Syncing a local `.proton-drive-linux` would land on the folder holding
|
||||
// this device's own profile (`find_device_child_folder` reuses by name),
|
||||
// and the sync engine would then delete or overwrite it.
|
||||
if name == pdfs_core::profile::PROFILE_DIR_NAME {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{name} is reserved for this device's profile backup"
|
||||
)));
|
||||
}
|
||||
|
||||
let device = self.ensure_device()?;
|
||||
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
|
||||
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
||||
@@ -439,6 +524,8 @@ impl Core {
|
||||
/// Remove a synced folder from the sync set. `delete_remote` also deletes its
|
||||
/// folder under the device root; otherwise the cloud copy is left in place.
|
||||
pub(crate) fn remove_sync_folder(&self, id: i64, delete_remote: bool) -> CoreResult<()> {
|
||||
let lock = self.sync_lock(id);
|
||||
let _guard = lock.lock();
|
||||
let folder = self
|
||||
.db
|
||||
.sync_folder_get(id)
|
||||
@@ -449,19 +536,16 @@ impl Core {
|
||||
// folder the daemon no longer tracks, and nothing would ever unmount it.
|
||||
// Tear it down first — including before trashing the remote tree it
|
||||
// serves, which would otherwise leave it answering for deleted nodes.
|
||||
if let Some((session, conn)) = self.mounts.lock().remove(&id) {
|
||||
if let Err(e) = umount_session_unblocked(session, conn) {
|
||||
warn!(id, error = %e, "unmount on-demand folder failed");
|
||||
} else {
|
||||
info!(id, path = %folder.local_path, "unmounted on-demand folder");
|
||||
}
|
||||
}
|
||||
if delete_remote
|
||||
&& let Some(uid) = parse_uid(&folder.remote_uid)
|
||||
&& let Err(e) = self.rt.block_on(self.client.trash_nodes(&[uid]))
|
||||
if let Some(mount) = self.mounts.lock().remove(&id)
|
||||
&& let Err(error) = commit_after_teardown(mount.teardown(), || ())
|
||||
{
|
||||
warn!(id, error = %e, "delete remote device folder failed");
|
||||
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
|
||||
return Err(CoreError::internal(format!(
|
||||
"cannot remove synced folder: unmount {} failed: {error}",
|
||||
folder.local_path
|
||||
)));
|
||||
}
|
||||
info!(id, path = %folder.local_path, "unmounted on-demand folder");
|
||||
if !self
|
||||
.db
|
||||
.sync_folder_remove(id)
|
||||
@@ -469,8 +553,18 @@ impl Core {
|
||||
{
|
||||
return Err(CoreError::invalid(format!("no synced folder with id {id}")));
|
||||
}
|
||||
drop(_guard);
|
||||
// Stop watching the folder we just dropped.
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||
if delete_remote
|
||||
&& let Some(uid) = parse_uid(&folder.remote_uid)
|
||||
&& let Err(e) = self
|
||||
.rt
|
||||
.block_on(self.client.trash_nodes(&[uid]))
|
||||
.and_then(batch::into_unit)
|
||||
{
|
||||
warn!(id, error = %e, "delete remote device folder failed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -489,6 +583,8 @@ impl Core {
|
||||
/// (devices.md Phase 3).
|
||||
pub(crate) fn fork_state(&self) -> Core {
|
||||
let mut fork = self.clone();
|
||||
fork.primary = false;
|
||||
let share_access = self.state.lock().share_access.clone();
|
||||
fork.state = Arc::new(Mutex::new(State {
|
||||
entries: HashMap::new(),
|
||||
by_uid: HashMap::new(),
|
||||
@@ -497,19 +593,17 @@ impl Core {
|
||||
active_writes: HashMap::new(),
|
||||
handles: HashMap::new(),
|
||||
next_fh: 1,
|
||||
access_changes: std::collections::HashSet::new(),
|
||||
share_access,
|
||||
db: self.db.clone(),
|
||||
}));
|
||||
// A fresh inode space needs a fresh notification channel: this fork's
|
||||
// session is the only one that knows these inodes, so it must be the one
|
||||
// notified about them. Filled in by `spawn_ondemand_mount`.
|
||||
fork.notifier = Arc::new(OnceLock::new());
|
||||
fork.session_live = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
// Size upgrades are keyed by inode, which is per-fork too.
|
||||
fork.size_upgrades = Arc::new(Mutex::new(HashMap::new()));
|
||||
// The daemon's single drain thread belongs to the primary mount, but it
|
||||
// drains ops for nodes that live *here*. Publishing this inode space is
|
||||
// what lets it find them; without it a queued create's node keeps its
|
||||
// `local~` uid after the upload lands and reads it as empty (B74).
|
||||
fork.register_state();
|
||||
fork
|
||||
}
|
||||
|
||||
@@ -525,49 +619,84 @@ impl Core {
|
||||
/// running — which, seeing a queued `ondemand`, also stops pulling down files
|
||||
/// it is about to evict ([`Core::push_pass`]).
|
||||
pub(crate) fn request_sync_folder_mode(&self, id: i64, mode: &str) -> CoreResult<String> {
|
||||
if mode != "mirror" && mode != "ondemand" {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"unknown mode {mode:?} (want mirror|ondemand)"
|
||||
)));
|
||||
}
|
||||
// Preserve the existing control behavior: reject an unsupported request
|
||||
// before looking up the folder, then validate the persisted source too.
|
||||
let target_mode = ApplicableMountMode::parse(mode).map_err(CoreError::invalid)?;
|
||||
let folder = self
|
||||
.db
|
||||
.sync_folder_get(id)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
||||
if folder.mode == mode {
|
||||
// Already there. A queued switch the other way is the user changing
|
||||
// their mind back before it landed, so withdraw it.
|
||||
if folder.pending_mode.is_some() {
|
||||
self.db
|
||||
.sync_folder_set_pending_mode(id, None)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||
return Ok(format!("staying {mode}"));
|
||||
let current_mode = ApplicableMountMode::parse(&folder.mode)
|
||||
.map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?;
|
||||
if current_mode == target_mode {
|
||||
let mode_str = target_mode.as_str();
|
||||
let lock = self.sync_lock(id);
|
||||
match lock.try_lock() {
|
||||
Some(_guard) => {
|
||||
let current = self
|
||||
.db
|
||||
.sync_folder_get(id)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||
.ok_or_else(|| {
|
||||
CoreError::not_found(format!("no synced folder with id {id}"))
|
||||
})?;
|
||||
let locked_mode =
|
||||
ApplicableMountMode::parse(¤t.mode).map_err(|error| {
|
||||
CoreError::invalid(format!("unsupported current mode: {error}"))
|
||||
})?;
|
||||
if locked_mode == target_mode {
|
||||
self.db
|
||||
.sync_folder_set_pending_mode(id, None)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||
return Ok(if current.pending_mode.is_some() {
|
||||
format!("staying {mode_str}")
|
||||
} else {
|
||||
format!("already {mode_str}")
|
||||
});
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// A transition or reconcile owns the folder lock. Record
|
||||
// this request instead of clearing the old target: if a
|
||||
// queued switch already passed its recheck, its mode write
|
||||
// preserves this opposite marker and the engine switches
|
||||
// back next.
|
||||
self.db
|
||||
.sync_folder_set_pending_mode(id, Some(mode_str))
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
||||
return Ok(format!("staying {mode_str}"));
|
||||
}
|
||||
}
|
||||
return Ok(format!("already {mode}"));
|
||||
}
|
||||
let mode = target_mode;
|
||||
let mode_str = target_mode.as_str();
|
||||
|
||||
match self.apply_sync_folder_mode(id, mode) {
|
||||
match self.apply_sync_folder_mode(id, mode, None) {
|
||||
Ok(message) => Ok(message),
|
||||
// Not switchable this instant — remember the intent instead of making
|
||||
// the user retry, and kick a pass to clear whatever is in the way.
|
||||
Err(SwitchBlocked::NotNow) => {
|
||||
self.db
|
||||
.sync_folder_set_pending_mode(id, Some(mode))
|
||||
.sync_folder_set_pending_mode(id, Some(mode_str))
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
||||
Ok(match mode {
|
||||
"ondemand" => format!(
|
||||
ApplicableMountMode::OnDemand => format!(
|
||||
"{} will go on-demand once its local changes are uploaded",
|
||||
base_name(&folder.local_path)
|
||||
),
|
||||
_ => format!(
|
||||
ApplicableMountMode::Mirror => format!(
|
||||
"{} will start mirroring once the current sync finishes",
|
||||
base_name(&folder.local_path)
|
||||
),
|
||||
})
|
||||
}
|
||||
Err(SwitchBlocked::Superseded) => Err(CoreError::internal(
|
||||
"direct mode request was unexpectedly superseded",
|
||||
)),
|
||||
Err(SwitchBlocked::Failed(e)) => Err(CoreError::internal(e)),
|
||||
}
|
||||
}
|
||||
@@ -581,27 +710,45 @@ impl Core {
|
||||
let Ok(Some(folder)) = self.db.sync_folder_get(id) else {
|
||||
return;
|
||||
};
|
||||
let Some(mode) = folder.pending_mode.as_deref() else {
|
||||
let Some(expected_mode) = folder.pending_mode.as_deref() else {
|
||||
return;
|
||||
};
|
||||
if folder.mode == mode {
|
||||
let _ = self.db.sync_folder_set_pending_mode(id, None);
|
||||
return;
|
||||
}
|
||||
match self.apply_sync_folder_mode(id, mode) {
|
||||
let mode = match select_mode_transition(&folder.mode, expected_mode) {
|
||||
Ok(Some(mode)) => mode,
|
||||
Ok(None) => {
|
||||
let _ = self.db.sync_folder_clear_pending_mode_if(id, expected_mode);
|
||||
return;
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(
|
||||
id,
|
||||
current_mode = %folder.mode,
|
||||
pending_mode = %expected_mode,
|
||||
%error,
|
||||
"withdrawing unsafe queued mode transition"
|
||||
);
|
||||
let _ = self.db.sync_folder_clear_pending_mode_if(id, expected_mode);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mode_str = mode.as_str();
|
||||
match self.apply_sync_folder_mode(id, mode, Some(expected_mode)) {
|
||||
Ok(message) => {
|
||||
info!(id, mode, "applied queued mode switch");
|
||||
info!(id, mode = mode_str, "applied queued mode switch");
|
||||
self.log_activity(ActivityKind::Sync, &message, "", true);
|
||||
}
|
||||
// Still blocked: the pass could not get everything up, so the local copy
|
||||
// is not safe to evict yet. Leave the request standing — the next pass
|
||||
// (poll, or the retry the engine schedules) tries again.
|
||||
Err(SwitchBlocked::NotNow) => {
|
||||
info!(id, mode, "queued mode switch still waiting");
|
||||
info!(id, mode = mode_str, "queued mode switch still waiting");
|
||||
}
|
||||
Err(SwitchBlocked::Superseded) => {
|
||||
info!(id, mode = mode_str, "queued mode switch was replaced");
|
||||
}
|
||||
Err(SwitchBlocked::Failed(e)) => {
|
||||
warn!(id, mode, error = %e, "queued mode switch failed; withdrawing");
|
||||
let _ = self.db.sync_folder_set_pending_mode(id, None);
|
||||
warn!(id, mode = mode_str, error = %e, "queued mode switch failed; withdrawing");
|
||||
let _ = self.db.sync_folder_clear_pending_mode_if(id, expected_mode);
|
||||
self.log_activity(
|
||||
ActivityKind::Sync,
|
||||
format!("couldn't switch {}", base_name(&folder.local_path)),
|
||||
@@ -627,8 +774,10 @@ impl Core {
|
||||
pub(crate) fn apply_sync_folder_mode(
|
||||
&self,
|
||||
id: i64,
|
||||
mode: &str,
|
||||
mode: ApplicableMountMode,
|
||||
expected_pending: Option<&str>,
|
||||
) -> Result<String, SwitchBlocked> {
|
||||
let mode_str = mode.as_str();
|
||||
// Hold the folder's lock for the whole switch so no reconcile pass can be
|
||||
// running over the tree we are about to evict (or start while we mount over
|
||||
// it). A pass in flight holds the lock for its full duration, so `try_lock`
|
||||
@@ -646,13 +795,18 @@ impl Core {
|
||||
.sync_folder_get(id)
|
||||
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?
|
||||
.ok_or_else(|| SwitchBlocked::Failed(format!("no synced folder with id {id}")))?;
|
||||
if folder.mode == mode {
|
||||
return Ok(format!("already {mode}"));
|
||||
if !pending_intent_matches(folder.pending_mode.as_deref(), expected_pending) {
|
||||
return Err(SwitchBlocked::Superseded);
|
||||
}
|
||||
let current_mode = ApplicableMountMode::parse(&folder.mode)
|
||||
.map_err(|e| SwitchBlocked::Failed(format!("unsupported current mode: {e}")))?;
|
||||
if current_mode == mode {
|
||||
return Ok(format!("already {mode_str}"));
|
||||
}
|
||||
let local = PathBuf::from(&folder.local_path);
|
||||
|
||||
match mode {
|
||||
"ondemand" => {
|
||||
ApplicableMountMode::OnDemand => {
|
||||
// Only flip a folder that is fully in sync — a failed reconcile means
|
||||
// local edits could still be un-uploaded, and we are about to delete
|
||||
// the local copy. Not an error: a pass makes this true, and the queued
|
||||
@@ -699,25 +853,28 @@ impl Core {
|
||||
if let Err(e) = evict_dir_contents(&local) {
|
||||
warn!(id, path = %local.display(), error = %e, "evict local dir contents failed");
|
||||
}
|
||||
let session = self.spawn_ondemand_mount(&local, root).map_err(|e| {
|
||||
let mount = self.spawn_ondemand_mount(&local, root).map_err(|e| {
|
||||
SwitchBlocked::Failed(format!(
|
||||
"mount ondemand failed after local cache eviction; switch back to mirror to restore it: {e}"
|
||||
))
|
||||
})?;
|
||||
self.mounts
|
||||
.lock()
|
||||
.insert(id, (session, fuse_connection_id(&local)));
|
||||
self.publish_secondary_mount(id, mount)
|
||||
.map_err(SwitchBlocked::Failed)?;
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||
self.db.sync_folder_set_state(id, "idle", now_secs()).ok();
|
||||
info!(id, path = %local.display(), "mounted sync folder on-demand");
|
||||
Ok(format!("{} is now on-demand", local.display()))
|
||||
}
|
||||
_ => {
|
||||
ApplicableMountMode::Mirror => {
|
||||
// ondemand→mirror: tear down the secondary mount first.
|
||||
if let Some((session, conn)) = self.mounts.lock().remove(&id)
|
||||
&& let Err(e) = umount_session_unblocked(session, conn)
|
||||
if let Some(mount) = self.mounts.lock().remove(&id)
|
||||
&& let Err(error) = commit_after_teardown(mount.teardown(), || ())
|
||||
{
|
||||
warn!(id, error = %e, "unmount on-demand folder failed");
|
||||
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
|
||||
return Err(SwitchBlocked::Failed(format!(
|
||||
"unmount {} failed; folder remains on-demand: {error}",
|
||||
local.display()
|
||||
)));
|
||||
}
|
||||
// The evicted local tree makes the old baseline lie ("everything
|
||||
// deleted locally"); clear it so the reconcile is a pure download.
|
||||
@@ -745,23 +902,52 @@ impl Core {
|
||||
&self,
|
||||
local: &Path,
|
||||
root: Node,
|
||||
) -> CoreResult<BackgroundSession> {
|
||||
clear_stale_mount(local);
|
||||
let mut config = Config::default();
|
||||
config.mount_options = vec![
|
||||
MountOption::FSName("protondrive".to_string()),
|
||||
MountOption::Subtype("protondrive".to_string()),
|
||||
MountOption::DefaultPermissions,
|
||||
];
|
||||
) -> CoreResult<SecondaryMount> {
|
||||
let core = self.fork_state();
|
||||
let fs = ProtonFs::new(core.clone(), root);
|
||||
let session = Session::new(fs, local, &config)
|
||||
.and_then(|s| s.spawn())
|
||||
let session_live = core.session_live.clone();
|
||||
// The fork owns this state/path registration. `spawn_session` only
|
||||
// marks it live after FUSE has spawned successfully.
|
||||
core.register_state(local);
|
||||
let session = spawn_session(&core, local, root)
|
||||
.map_err(|e| CoreError::internal(format!("mount {}: {e}", local.display())))?;
|
||||
// Hand this fork its own session's notification channel, so a background
|
||||
// size upgrade can drop the kernel's cached attrs for these inodes.
|
||||
let _ = core.notifier.set(session.notifier());
|
||||
Ok(session)
|
||||
Ok(SecondaryMount::new(
|
||||
session,
|
||||
fuse_connection_id(local),
|
||||
session_live,
|
||||
))
|
||||
}
|
||||
|
||||
/// Publish one newly spawned secondary or explicitly tear it down when
|
||||
/// shutdown has closed publication or another session already owns the id.
|
||||
fn publish_secondary_mount(&self, id: i64, mount: SecondaryMount) -> Result<(), String> {
|
||||
let rejected = self.mounts.lock().insert(id, mount);
|
||||
let Err((reason, mount)) = rejected else {
|
||||
return Ok(());
|
||||
};
|
||||
let reason = match reason {
|
||||
SecondaryInsertRejection::Closed => "daemon is shutting down",
|
||||
SecondaryInsertRejection::Duplicate => "folder already has a live mount",
|
||||
};
|
||||
match mount.teardown() {
|
||||
Ok(()) => Err(reason.to_string()),
|
||||
Err(error) => Err(format!("{reason}; rejected mount teardown failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_restore_error_if_current(&self, snapshot: &StoredSyncFolder) {
|
||||
let lock = self.sync_lock(snapshot.id);
|
||||
let _guard = lock.lock();
|
||||
if self
|
||||
.db
|
||||
.sync_folder_get(snapshot.id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some_and(|current| restore_snapshot_matches(snapshot, ¤t))
|
||||
{
|
||||
let _ = self
|
||||
.db
|
||||
.sync_folder_set_state(snapshot.id, "error", now_secs());
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-establish FUSE mounts for folders left in `ondemand` mode across a daemon
|
||||
@@ -782,14 +968,57 @@ impl Core {
|
||||
if folder.mode != "ondemand" {
|
||||
continue;
|
||||
}
|
||||
let local = PathBuf::from(&folder.local_path);
|
||||
// A daemon killed before it could unmount (systemd's stop timeout,
|
||||
// say) leaves this path attached to a dead FUSE connection: it still
|
||||
// exists, but every stat on it fails with ENOTCONN — so `is_dir()`
|
||||
// reads it as *missing* and the folder gets parked in `error` on
|
||||
// every subsequent start, with no way back but a manual
|
||||
// `fusermount -u`. Detach the corpse first; the directory
|
||||
// underneath then restores normally.
|
||||
let Some(root_uid) = parse_uid(&folder.remote_uid) else {
|
||||
warn!(id = folder.id, "restore on-demand: bad remote uid");
|
||||
self.mark_restore_error_if_current(&folder);
|
||||
continue;
|
||||
};
|
||||
let root = match self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(std::slice::from_ref(&root_uid)))
|
||||
{
|
||||
Ok(v) => match v.into_iter().next() {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
warn!(id = folder.id, "restore on-demand: remote folder gone");
|
||||
self.mark_restore_error_if_current(&folder);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
warn!(id = folder.id, error = %e, "restore on-demand: fetch remote failed");
|
||||
self.mark_restore_error_if_current(&folder);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let lock = self.sync_lock(folder.id);
|
||||
let _guard = lock.lock();
|
||||
let current = match self.db.sync_folder_get(folder.id) {
|
||||
Ok(Some(current)) if restore_snapshot_matches(&folder, ¤t) => current,
|
||||
Ok(_) => {
|
||||
info!(
|
||||
id = folder.id,
|
||||
"restore on-demand: folder changed while fetching"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(
|
||||
id = folder.id,
|
||||
?error,
|
||||
"restore on-demand: cannot re-read folder"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !self.mounts.lock().is_accepting() {
|
||||
info!(id = folder.id, "restore on-demand: shutdown in progress");
|
||||
continue;
|
||||
}
|
||||
let local = PathBuf::from(¤t.local_path);
|
||||
// A daemon killed before it could unmount can leave a dead FUSE
|
||||
// connection over the path. Clear it only after the row identity
|
||||
// and mode have been revalidated under the folder lock.
|
||||
if is_stale_mount(&local) {
|
||||
warn!(id = folder.id, path = %local.display(), "restore on-demand: clearing stale mount");
|
||||
clear_stale_mount(&local);
|
||||
@@ -801,11 +1030,6 @@ impl Core {
|
||||
.sync_folder_set_state(folder.id, "error", now_secs());
|
||||
continue;
|
||||
}
|
||||
// An `ondemand` folder's local dir is empty by construction — the switch
|
||||
// evicts it. Finding files there means the row is lying (a switch that
|
||||
// died between persisting the mode and evicting), and mounting over them
|
||||
// would hide real local data behind the remote tree. Leave the files
|
||||
// alone and let the user resolve it.
|
||||
match dir_is_empty(&local) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
@@ -827,40 +1051,16 @@ impl Core {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Some(root_uid) = parse_uid(&folder.remote_uid) else {
|
||||
warn!(id = folder.id, "restore on-demand: bad remote uid");
|
||||
continue;
|
||||
};
|
||||
let root = match self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(std::slice::from_ref(&root_uid)))
|
||||
{
|
||||
Ok(v) => match v.into_iter().next() {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
warn!(id = folder.id, "restore on-demand: remote folder gone");
|
||||
let _ = self
|
||||
.db
|
||||
.sync_folder_set_state(folder.id, "error", now_secs());
|
||||
continue;
|
||||
match self.spawn_ondemand_mount(&local, root) {
|
||||
Ok(mount) => match self.publish_secondary_mount(folder.id, mount) {
|
||||
Ok(()) => {
|
||||
let _ = self.db.sync_folder_set_state(folder.id, "idle", now_secs());
|
||||
info!(id = folder.id, path = %local.display(), "remounted on-demand folder");
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(id = folder.id, %error, "restore on-demand: mount rejected");
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
warn!(id = folder.id, error = %e, "restore on-demand: fetch remote failed");
|
||||
let _ = self
|
||||
.db
|
||||
.sync_folder_set_state(folder.id, "error", now_secs());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match self.spawn_ondemand_mount(&local, root) {
|
||||
Ok(session) => {
|
||||
self.mounts
|
||||
.lock()
|
||||
.insert(folder.id, (session, fuse_connection_id(&local)));
|
||||
let _ = self.db.sync_folder_set_state(folder.id, "idle", now_secs());
|
||||
info!(id = folder.id, path = %local.display(), "remounted on-demand folder");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(id = folder.id, error = %e, "restore on-demand: mount failed");
|
||||
let _ = self
|
||||
@@ -871,3 +1071,118 @@ impl Core {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pdfs_core::db::StoredSyncFolder;
|
||||
|
||||
use super::{
|
||||
ApplicableMountMode, commit_after_teardown, pending_intent_matches,
|
||||
restore_snapshot_matches, select_mode_transition,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn unknown_mode_cannot_select_a_destructive_transition() {
|
||||
let mut unmounts = 0;
|
||||
let mut baseline_clears = 0;
|
||||
let mut mode_writes = 0;
|
||||
if let Ok(mode) = ApplicableMountMode::parse("streaming") {
|
||||
match mode {
|
||||
ApplicableMountMode::Mirror => {
|
||||
unmounts += 1;
|
||||
baseline_clears += 1;
|
||||
mode_writes += 1;
|
||||
}
|
||||
ApplicableMountMode::OnDemand => {
|
||||
mode_writes += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!((unmounts, baseline_clears, mode_writes), (0, 0, 0));
|
||||
assert_eq!(
|
||||
ApplicableMountMode::parse("streaming"),
|
||||
Err("unknown mode \"streaming\" (want mirror|ondemand)".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
ApplicableMountMode::parse("mirror"),
|
||||
Ok(ApplicableMountMode::Mirror)
|
||||
);
|
||||
assert_eq!(
|
||||
ApplicableMountMode::parse("ondemand"),
|
||||
Ok(ApplicableMountMode::OnDemand)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_source_mode_with_pending_mirror_cannot_select_a_transition() {
|
||||
let mut destructive_helper_reached = false;
|
||||
let selected = select_mode_transition("streaming", "mirror");
|
||||
if let Ok(Some(_)) = selected {
|
||||
destructive_helper_reached = true;
|
||||
}
|
||||
|
||||
assert!(!destructive_helper_reached);
|
||||
assert_eq!(
|
||||
selected,
|
||||
Err(
|
||||
"unsupported current mode: unknown mode \"streaming\" (want mirror|ondemand)"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
fn folder(local_path: &str, remote_uid: &str, mode: &str) -> StoredSyncFolder {
|
||||
StoredSyncFolder {
|
||||
id: 7,
|
||||
local_path: local_path.to_string(),
|
||||
remote_uid: remote_uid.to_string(),
|
||||
remote_share_id: "share".to_string(),
|
||||
mode: mode.to_string(),
|
||||
pending_mode: None,
|
||||
state: "idle".to_string(),
|
||||
last_sync: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_restore_identity_or_mode_is_rejected() {
|
||||
let snapshot = folder("/mnt/folder", "volume~node", "ondemand");
|
||||
assert!(restore_snapshot_matches(
|
||||
&snapshot,
|
||||
&folder("/mnt/folder", "volume~node", "ondemand")
|
||||
));
|
||||
assert!(!restore_snapshot_matches(
|
||||
&snapshot,
|
||||
&folder("/mnt/replaced", "volume~node", "ondemand")
|
||||
));
|
||||
assert!(!restore_snapshot_matches(
|
||||
&snapshot,
|
||||
&folder("/mnt/folder", "volume~other", "ondemand")
|
||||
));
|
||||
assert!(!restore_snapshot_matches(
|
||||
&snapshot,
|
||||
&folder("/mnt/folder", "volume~node", "mirror")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teardown_failure_prevents_follow_up_state_changes() {
|
||||
let mut commits = 0;
|
||||
let result = commit_after_teardown::<_, &str>(Err("busy"), || {
|
||||
commits += 1;
|
||||
});
|
||||
assert_eq!(result, Err("busy"));
|
||||
assert_eq!(commits, 0);
|
||||
|
||||
commit_after_teardown::<_, &str>(Ok(()), || commits += 1).unwrap();
|
||||
assert_eq!(commits, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canceled_or_replaced_queued_intent_cannot_transition() {
|
||||
assert!(pending_intent_matches(Some("ondemand"), Some("ondemand")));
|
||||
assert!(!pending_intent_matches(None, Some("ondemand")));
|
||||
assert!(!pending_intent_matches(Some("mirror"), Some("ondemand")));
|
||||
assert!(pending_intent_matches(Some("mirror"), None));
|
||||
}
|
||||
}
|
||||
@@ -25,9 +25,10 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use pdfs_core::batch;
|
||||
use pdfs_core::cache::{Baseline, StagedWrite};
|
||||
use pdfs_core::control::{ActivityKind, TransferDirection};
|
||||
use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp};
|
||||
use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, RenameMeta};
|
||||
use proton_drive_rs::Node;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use tracing::{debug, error, info, warn};
|
||||
@@ -40,6 +41,78 @@ use super::{
|
||||
now_millis, now_secs, parse_node_uid,
|
||||
};
|
||||
|
||||
const DRAIN_ACCESS_RECHECK: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum DrainDisposition {
|
||||
Applied,
|
||||
AccessDeferred,
|
||||
}
|
||||
|
||||
fn pending_op_authorities(op: &PendingOp) -> Result<Vec<NodeUid>, Box<dyn std::error::Error>> {
|
||||
let uid = || -> Result<NodeUid, Box<dyn std::error::Error>> {
|
||||
parse_node_uid(&op.uid)
|
||||
.ok_or_else(|| Box::<dyn std::error::Error>::from("pending op has an unparseable uid"))
|
||||
};
|
||||
let parent = || -> Result<NodeUid, Box<dyn std::error::Error>> {
|
||||
let parent = op
|
||||
.parent_uid
|
||||
.as_deref()
|
||||
.ok_or("pending op has no parent authority")?;
|
||||
parse_node_uid(parent).ok_or_else(|| {
|
||||
Box::<dyn std::error::Error>::from("pending op has an unparseable parent")
|
||||
})
|
||||
};
|
||||
match op.kind.as_str() {
|
||||
OP_REVISION | OP_TRASH => Ok(vec![uid()?]),
|
||||
OP_CREATE | OP_MKDIR => Ok(vec![parent()?]),
|
||||
OP_RENAME => {
|
||||
let mut authorities = vec![uid()?];
|
||||
if let Some(json) = op.meta_json.as_deref() {
|
||||
let meta: RenameMeta = serde_json::from_str(json)?;
|
||||
authorities.push(
|
||||
parse_node_uid(&meta.original_parent_uid)
|
||||
.ok_or("rename op has an unparseable original parent")?,
|
||||
);
|
||||
} else {
|
||||
// Rows written by 1.1.1 predate foreign shared mounts. Their
|
||||
// source tree was necessarily owned, so the node plus desired
|
||||
// destination are the complete available authority set.
|
||||
}
|
||||
authorities.push(parent()?);
|
||||
authorities.dedup();
|
||||
Ok(authorities)
|
||||
}
|
||||
other => Err(format!("unknown pending op kind {other:?}").into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_authorized_drain(
|
||||
op: &PendingOp,
|
||||
mut require: impl FnMut(&NodeUid) -> Result<(), fuser::Errno>,
|
||||
apply: impl FnOnce() -> Result<(), Box<dyn std::error::Error>>,
|
||||
) -> Result<DrainDisposition, Box<dyn std::error::Error>> {
|
||||
// This check is the admission/linearization point. A downgrade that lands
|
||||
// first defers the op; one observed afterwards does not revoke this admitted
|
||||
// attempt. Every later queued operation rechecks. No permission lock is
|
||||
// held across remote latency.
|
||||
for authority in pending_op_authorities(op)? {
|
||||
match require(&authority) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.code() == libc::EACCES => {
|
||||
return Ok(DrainDisposition::AccessDeferred);
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(
|
||||
format!("drain access check failed with errno {}", error.code()).into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
apply()?;
|
||||
Ok(DrainDisposition::Applied)
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Drain the pending-op queue: the background half of every write
|
||||
/// (offline.md Phase 3).
|
||||
@@ -69,7 +142,30 @@ impl Core {
|
||||
self.wait_for_drain_work_or_due();
|
||||
continue;
|
||||
};
|
||||
if let Err(e) = self.drain_op(&op) {
|
||||
let outcome = run_authorized_drain(
|
||||
&op,
|
||||
|uid| self.require_uid_writable(uid),
|
||||
|| self.drain_op(&op),
|
||||
);
|
||||
if matches!(outcome, Ok(DrainDisposition::AccessDeferred)) {
|
||||
let next_attempt_at = now_millis() + DRAIN_ACCESS_RECHECK.as_millis() as i64;
|
||||
if let Err(error) = self.db.defer_op_without_attempt(op.id, next_attempt_at) {
|
||||
error!(
|
||||
uid = %op.uid,
|
||||
%error,
|
||||
"deferring access-blocked pending operation failed"
|
||||
);
|
||||
self.wait_for_drain_work();
|
||||
} else {
|
||||
debug!(
|
||||
uid = %op.uid,
|
||||
next_attempt_at,
|
||||
"pending operation deferred until access is writable"
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = outcome {
|
||||
let attempts = op.attempts + 1;
|
||||
let err_str = e.to_string();
|
||||
// Never infer that user data is disposable from an error string
|
||||
@@ -245,6 +341,7 @@ impl Core {
|
||||
match self
|
||||
.rt
|
||||
.block_on(self.client.trash_nodes(std::slice::from_ref(&uid)))
|
||||
.and_then(batch::into_unit)
|
||||
{
|
||||
Ok(()) => {}
|
||||
Err(e) if is_gone(&e) => {
|
||||
@@ -252,7 +349,7 @@ impl Core {
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
self.db.delete_op(op.id)?;
|
||||
self.db.complete_trash_op(op.id, &uid)?;
|
||||
self.own_sealed_revs.lock().remove(&uid);
|
||||
self.invalidate_trash();
|
||||
self.log_activity(ActivityKind::Trash, &name, "trashed", true);
|
||||
@@ -996,8 +1093,186 @@ fn is_own_self_supersede(complete: bool, remote_rev: Option<&str>, own_rev: Opti
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pdfs_core::Access;
|
||||
use pdfs_core::db::Db;
|
||||
use proton_drive_rs::NodeKind;
|
||||
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
|
||||
use std::cell::Cell;
|
||||
|
||||
fn pending(kind: &str) -> PendingOp {
|
||||
PendingOp {
|
||||
id: 0,
|
||||
kind: kind.to_string(),
|
||||
uid: NodeUid::new(VolumeId::from("v"), LinkId::from("node")).to_string(),
|
||||
parent_uid: Some(NodeUid::new(VolumeId::from("v"), LinkId::from("parent")).to_string()),
|
||||
name: Some("name".to_string()),
|
||||
blob_path: (kind == OP_REVISION).then(|| "/staging/blob".to_string()),
|
||||
meta_json: None,
|
||||
created_at: 1,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_authorities_match_each_operation_kind() {
|
||||
for kind in [OP_REVISION, OP_TRASH] {
|
||||
assert_eq!(pending_op_authorities(&pending(kind)).unwrap().len(), 1);
|
||||
}
|
||||
for kind in [OP_CREATE, OP_MKDIR] {
|
||||
let authorities = pending_op_authorities(&pending(kind)).unwrap();
|
||||
assert_eq!(authorities.len(), 1);
|
||||
assert_eq!(authorities[0].to_string(), "v~parent");
|
||||
}
|
||||
let authorities = pending_op_authorities(&pending(OP_RENAME)).unwrap();
|
||||
assert_eq!(authorities.len(), 2);
|
||||
assert_eq!(authorities[0].to_string(), "v~node");
|
||||
assert_eq!(authorities[1].to_string(), "v~parent");
|
||||
}
|
||||
|
||||
fn folder_node(link: &str, parent: Option<&str>) -> Node {
|
||||
Node {
|
||||
uid: NodeUid::new(VolumeId::from("v"), LinkId::from(link)),
|
||||
parent_uid: parent
|
||||
.map(|parent| NodeUid::new(VolumeId::from("v"), LinkId::from(parent))),
|
||||
kind: NodeKind::Folder,
|
||||
name: link.to_string(),
|
||||
creation_time: 0,
|
||||
modification_time: 0,
|
||||
trashed: false,
|
||||
is_shared: false,
|
||||
is_shared_publicly: false,
|
||||
signature_email: None,
|
||||
membership: None,
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_cross_parent_rename_retains_viewer_source_authority_after_local_move() {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static N: AtomicU64 = AtomicU64::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pdfs-drain-rename-access-{}-{}",
|
||||
std::process::id(),
|
||||
N.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let db = Db::open(&dir.join("cache.db")).unwrap();
|
||||
let source = folder_node("source", None);
|
||||
let destination = folder_node("destination", None);
|
||||
let mut moved = file_node(1, 0, None);
|
||||
moved.parent_uid = Some(destination.uid.clone());
|
||||
for node in [&source, &destination, &moved] {
|
||||
db.upsert_node(node).unwrap();
|
||||
}
|
||||
db.set_share_access(&source.uid, Access::Viewer).unwrap();
|
||||
db.set_share_access(&destination.uid, Access::Editor)
|
||||
.unwrap();
|
||||
|
||||
let mut op = pending(OP_RENAME);
|
||||
op.uid = moved.uid.to_string();
|
||||
op.parent_uid = Some(destination.uid.to_string());
|
||||
op.meta_json = Some(
|
||||
serde_json::to_string(&RenameMeta {
|
||||
original_parent_uid: source.uid.to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
let authorities = pending_op_authorities(&op).unwrap();
|
||||
assert_eq!(authorities.len(), 3);
|
||||
assert_eq!(authorities[1], source.uid);
|
||||
|
||||
let applied = Cell::new(false);
|
||||
let disposition = run_authorized_drain(
|
||||
&op,
|
||||
|uid| {
|
||||
db.effective_node_access(uid)
|
||||
.unwrap()
|
||||
.filter(|access| access.writable())
|
||||
.map(|_| ())
|
||||
.ok_or(fuser::Errno::EACCES)
|
||||
},
|
||||
|| {
|
||||
applied.set(true);
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(disposition, DrainDisposition::AccessDeferred);
|
||||
assert!(!applied.get());
|
||||
assert_eq!(
|
||||
db.effective_node_access(&moved.uid).unwrap(),
|
||||
Some(Access::Editor),
|
||||
"the node's optimistic destination ancestry is writable"
|
||||
);
|
||||
drop(db);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewer_deferred_drain_never_applies_or_consumes_an_attempt() {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static N: AtomicU64 = AtomicU64::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pdfs-drain-access-{}-{}",
|
||||
std::process::id(),
|
||||
N.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let db = Db::open(&dir.join("cache.db")).unwrap();
|
||||
let mut queued = pending(OP_REVISION);
|
||||
queued.attempts = 2;
|
||||
db.enqueue_op(&queued).unwrap();
|
||||
let op = db.pending_ops().unwrap().remove(0);
|
||||
let before_count = db.pending_op_counts().unwrap();
|
||||
let applied = Cell::new(false);
|
||||
|
||||
let disposition = run_authorized_drain(
|
||||
&op,
|
||||
|_| Err(fuser::Errno::EACCES),
|
||||
|| {
|
||||
applied.set(true);
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(disposition, DrainDisposition::AccessDeferred);
|
||||
assert!(!applied.get(), "no API surrogate may run while read-only");
|
||||
db.defer_op_without_attempt(op.id, 5_000).unwrap();
|
||||
|
||||
let retained = db.pending_ops().unwrap();
|
||||
let after_count = db.pending_op_counts().unwrap();
|
||||
assert_eq!(retained.len(), 1);
|
||||
assert_eq!(retained[0].attempts, op.attempts);
|
||||
assert_eq!(after_count.uploads, before_count.uploads);
|
||||
assert_eq!(after_count.changes, before_count.changes);
|
||||
drop(db);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_and_editor_drain_authorities_apply_normally() {
|
||||
for access in [Access::Owner, Access::Editor] {
|
||||
let applied = Cell::new(false);
|
||||
let disposition = run_authorized_drain(
|
||||
&pending(OP_REVISION),
|
||||
|_| access.writable().then_some(()).ok_or(fuser::Errno::EACCES),
|
||||
|| {
|
||||
applied.set(true);
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(disposition, DrainDisposition::Applied);
|
||||
assert!(applied.get());
|
||||
}
|
||||
}
|
||||
|
||||
fn file_node(mtime: i64, size: i64, rev: Option<&str>) -> Node {
|
||||
Node {
|
||||
@@ -1019,6 +1294,9 @@ mod tests {
|
||||
is_shared: false,
|
||||
is_shared_publicly: false,
|
||||
signature_email: None,
|
||||
membership: None,
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ impl Filesystem for ProtonFs {
|
||||
}
|
||||
)
|
||||
.then(|| (e.parent, e.uid.clone()));
|
||||
(self.attr(ino.0, &e.node), provisional)
|
||||
(self.attr(ino.0, &e.node, e.access), provisional)
|
||||
}
|
||||
None => {
|
||||
reply.error(Errno::ENOENT);
|
||||
@@ -79,7 +79,7 @@ impl Filesystem for ProtonFs {
|
||||
let attr = {
|
||||
let st = fs.core.state.lock();
|
||||
match st.entries.get(&ino.0) {
|
||||
Some(e) => fs.attr(ino.0, &e.node),
|
||||
Some(e) => fs.attr(ino.0, &e.node, e.access),
|
||||
// Forgotten while we waited.
|
||||
None => attr,
|
||||
}
|
||||
@@ -110,10 +110,15 @@ impl Filesystem for ProtonFs {
|
||||
}
|
||||
|
||||
fn open(&self, _req: &Request, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) {
|
||||
let write_requested = flags.acc_mode() != OpenAccMode::O_RDONLY;
|
||||
let (uid, base_mtime, base_size, base_revision_id) = {
|
||||
let mut st = self.core.state.lock();
|
||||
match st.entries.get_mut(&ino.0) {
|
||||
Some(e) if e.node.is_file() => {
|
||||
if write_requested && !e.writable() {
|
||||
reply.error(Errno::EACCES);
|
||||
return;
|
||||
}
|
||||
e.open_count = e.open_count.saturating_add(1);
|
||||
(
|
||||
e.uid.clone(),
|
||||
@@ -315,6 +320,10 @@ impl Filesystem for ProtonFs {
|
||||
reply: ReplyCreate,
|
||||
) {
|
||||
let parent = parent.0;
|
||||
if let Err(error) = self.core.require_writable(parent) {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
let name = match fuse_name(name) {
|
||||
Ok(name) => name,
|
||||
Err(error) => {
|
||||
@@ -346,6 +355,10 @@ impl Filesystem for ProtonFs {
|
||||
_lock_owner: Option<LockOwner>,
|
||||
reply: ReplyWrite,
|
||||
) {
|
||||
if let Err(error) = self.core.require_writable(ino.0) {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
let fh = fh.0;
|
||||
// Stage the bytes straight into the scratch file (no base download): only
|
||||
// the untouched remainder is pulled from the remote, and only at commit.
|
||||
@@ -413,6 +426,10 @@ impl Filesystem for ProtonFs {
|
||||
_flags: Option<BsdFileFlags>,
|
||||
reply: ReplyAttr,
|
||||
) {
|
||||
if let Err(error) = self.core.require_writable(ino.0) {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
// Only resizes change remote state; everything else (mode/owner/times) is
|
||||
// accepted and ignored so tools that chmod/utimes after writing succeed.
|
||||
if let Some(size) = size {
|
||||
@@ -476,6 +493,10 @@ impl Filesystem for ProtonFs {
|
||||
mode: i32,
|
||||
reply: ReplyEmpty,
|
||||
) {
|
||||
if let Err(error) = self.core.require_writable(ino.0) {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
let new_end = offset.saturating_add(length);
|
||||
let keep_size = (mode & 1) != 0; // FALLOC_FL_KEEP_SIZE
|
||||
|
||||
@@ -637,18 +658,7 @@ impl Filesystem for ProtonFs {
|
||||
) {
|
||||
let (handle, unlinked_uid) = {
|
||||
let mut st = self.core.state.lock();
|
||||
let unlinked_uid = if let Some(entry) = st.entries.get_mut(&_ino.0) {
|
||||
entry.open_count = entry.open_count.saturating_sub(1);
|
||||
if entry.open_count == 0 && entry.unlinked {
|
||||
let uid = entry.uid.clone();
|
||||
st.forget(&uid);
|
||||
Some(uid)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let unlinked_uid = release_unlinked_entry(&mut st, _ino.0);
|
||||
let ino = st.handles.remove(&fh.0);
|
||||
let h = ino.and_then(|i| {
|
||||
let aw = st.active_writes.get_mut(&i)?;
|
||||
@@ -679,6 +689,10 @@ impl Filesystem for ProtonFs {
|
||||
|
||||
fn unlink(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) {
|
||||
let parent = parent.0;
|
||||
if let Err(error) = self.core.require_writable(parent) {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
let name_str = match fuse_name(name) {
|
||||
Ok(name) => name,
|
||||
Err(error) => {
|
||||
@@ -695,6 +709,10 @@ impl Filesystem for ProtonFs {
|
||||
|
||||
fn rmdir(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) {
|
||||
let parent = parent.0;
|
||||
if let Err(error) = self.core.require_writable(parent) {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
let name_str = match fuse_name(name) {
|
||||
Ok(name) => name,
|
||||
Err(error) => {
|
||||
@@ -720,6 +738,10 @@ impl Filesystem for ProtonFs {
|
||||
reply: ReplyEntry,
|
||||
) {
|
||||
let parent = parent.0;
|
||||
if let Err(error) = self.core.require_writable(parent) {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
let name = match fuse_name(name) {
|
||||
Ok(name) => name,
|
||||
Err(error) => {
|
||||
@@ -748,6 +770,14 @@ impl Filesystem for ProtonFs {
|
||||
) {
|
||||
let parent = parent.0;
|
||||
let newparent = newparent.0;
|
||||
if let Err(error) = self
|
||||
.core
|
||||
.require_writable(parent)
|
||||
.and_then(|()| self.core.require_writable(newparent))
|
||||
{
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
let name = match fuse_name(name) {
|
||||
Ok(name) => name,
|
||||
Err(error) => {
|
||||
@@ -771,6 +801,19 @@ impl Filesystem for ProtonFs {
|
||||
});
|
||||
}
|
||||
|
||||
fn access(&self, _req: &Request, ino: INodeNo, mask: AccessFlags, reply: ReplyEmpty) {
|
||||
let state = self.core.state.lock();
|
||||
let Some(entry) = state.entries.get(&ino.0) else {
|
||||
reply.error(Errno::ENOENT);
|
||||
return;
|
||||
};
|
||||
if !access_allowed(entry.node.is_folder(), entry.access, mask) {
|
||||
reply.error(Errno::EACCES);
|
||||
} else {
|
||||
reply.ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn getxattr(&self, _req: &Request, ino: INodeNo, name: &OsStr, size: u32, reply: ReplyXattr) {
|
||||
// Always off the dispatch loop: a miss goes to the wire, and a lister
|
||||
// that stats a directory issues one of these per file per advertised
|
||||
@@ -935,6 +978,11 @@ pub struct ProtonFs {
|
||||
gid: u32,
|
||||
}
|
||||
|
||||
pub(crate) fn access_allowed(is_dir: bool, access: Access, mask: AccessFlags) -> bool {
|
||||
(!mask.contains(AccessFlags::W_OK) || access.writable())
|
||||
&& (!mask.contains(AccessFlags::X_OK) || is_dir)
|
||||
}
|
||||
|
||||
impl ProtonFs {
|
||||
/// Build the filesystem rooted at `root` (the user's My Files folder).
|
||||
pub(super) fn new(core: Core, root: Node) -> Self {
|
||||
@@ -944,12 +992,22 @@ impl ProtonFs {
|
||||
warn!(uid = %root.uid, error = %e, "db upsert root failed");
|
||||
}
|
||||
st.by_uid.insert(root.uid.clone(), ROOT_INO);
|
||||
// The filesystem root is always owned — it must never be
|
||||
// classified as a shared root, even when the API returns
|
||||
// membership data (which happens when the owner shares the
|
||||
// folder with someone else). Clean up any stale share_access
|
||||
// row that a previous downgrade_all_share_access may have
|
||||
// written for this uid, so hydrate_access cannot pick it up.
|
||||
let access = Access::Owner;
|
||||
st.share_access.remove(&root.uid);
|
||||
let _ = st.db.delete_share_access(&root.uid);
|
||||
st.entries.insert(
|
||||
ROOT_INO,
|
||||
Entry {
|
||||
uid: root.uid.clone(),
|
||||
parent: ROOT_INO,
|
||||
node: root,
|
||||
access,
|
||||
lookup_count: 1,
|
||||
open_count: 0,
|
||||
unlinked: false,
|
||||
@@ -991,7 +1049,7 @@ impl ProtonFs {
|
||||
}
|
||||
)
|
||||
.then(|| (e.parent, e.uid.clone()));
|
||||
(ino, self.attr(ino, &e.node), provisional)
|
||||
(ino, self.attr(ino, &e.node, e.access), provisional)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1023,7 +1081,7 @@ impl ProtonFs {
|
||||
let st = self.core.state.lock();
|
||||
st.entries
|
||||
.get(&ino)
|
||||
.map_or(attr, |e| self.attr(ino, &e.node))
|
||||
.map_or(attr, |e| self.attr(ino, &e.node, e.access))
|
||||
};
|
||||
reply.entry(&TTL, &attr, Generation(0));
|
||||
}
|
||||
@@ -1065,11 +1123,12 @@ impl ProtonFs {
|
||||
reply.ok();
|
||||
}
|
||||
|
||||
fn attr(&self, ino: u64, node: &Node) -> FileAttr {
|
||||
let (kind, perm) = match node.kind {
|
||||
NodeKind::Folder => (FileType::Directory, 0o755),
|
||||
NodeKind::File { .. } => (FileType::RegularFile, 0o644),
|
||||
fn attr(&self, ino: u64, node: &Node, access: Access) -> FileAttr {
|
||||
let kind = match node.kind {
|
||||
NodeKind::Folder => FileType::Directory,
|
||||
NodeKind::File { .. } => FileType::RegularFile,
|
||||
};
|
||||
let perm = perm_bits(node.is_folder(), access);
|
||||
let size = node_size(node);
|
||||
let mtime = unix_secs(node.modification_time);
|
||||
let crtime = unix_secs(node.creation_time);
|
||||
@@ -1097,6 +1156,10 @@ impl ProtonFs {
|
||||
/// subtrees, so an `rmdir` of a non-empty dir behaves the same).
|
||||
/// The body of [`Filesystem::create`], run off the dispatch loop.
|
||||
fn serve_create(&self, parent: u64, name: &str, reply: ReplyCreate) {
|
||||
if let Err(error) = self.core.require_writable(parent) {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
if let Err(e) = self.core.ensure_children(parent) {
|
||||
reply.error(e);
|
||||
return;
|
||||
@@ -1128,6 +1191,10 @@ impl ProtonFs {
|
||||
let node =
|
||||
if !transient && self.core.online.load(Ordering::Relaxed) && !is_local_uid(&parent_uid)
|
||||
{
|
||||
if let Err(error) = self.core.require_uid_writable(&parent_uid) {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
// Create an empty file on the remote so it has a real uid immediately;
|
||||
// written bytes are buffered and sealed as a new revision on close.
|
||||
let new_uid = match self.core.rt.block_on(self.core.client.upload_file(
|
||||
@@ -1211,7 +1278,10 @@ impl ProtonFs {
|
||||
});
|
||||
aw.open_count += 1;
|
||||
st.handles.insert(fh, ino);
|
||||
let attr = self.attr(ino, &st.entries.get(&ino).unwrap().node);
|
||||
let entry = st.entries.get(&ino).unwrap();
|
||||
let attr = self.attr(ino, &entry.node, entry.access);
|
||||
drop(st);
|
||||
self.core.flush_access_changes();
|
||||
reply.created(
|
||||
&TTL,
|
||||
&attr,
|
||||
@@ -1223,6 +1293,10 @@ impl ProtonFs {
|
||||
|
||||
/// The body of [`Filesystem::mkdir`], run off the dispatch loop.
|
||||
fn serve_mkdir(&self, parent: u64, name: &str, reply: ReplyEntry) {
|
||||
if let Err(error) = self.core.require_writable(parent) {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
if let Err(e) = self.core.ensure_children(parent) {
|
||||
reply.error(e);
|
||||
return;
|
||||
@@ -1245,6 +1319,10 @@ impl ProtonFs {
|
||||
// queued — the folder becomes a placeholder that the drain turns into a
|
||||
// real one (offline.md Phase 3b).
|
||||
let node = if self.core.online.load(Ordering::Relaxed) && !is_local_uid(&parent_uid) {
|
||||
if let Err(error) = self.core.require_uid_writable(&parent_uid) {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
let new_uid =
|
||||
match self
|
||||
.core
|
||||
@@ -1292,12 +1370,19 @@ impl ProtonFs {
|
||||
warn!(%uid, error = %e, "db set_listed(true) failed for local folder");
|
||||
}
|
||||
}
|
||||
let attr = self.attr(ino, &st.entries.get(&ino).unwrap().node);
|
||||
let entry = st.entries.get(&ino).unwrap();
|
||||
let attr = self.attr(ino, &entry.node, entry.access);
|
||||
drop(st);
|
||||
self.core.flush_access_changes();
|
||||
reply.entry(&TTL, &attr, Generation(0));
|
||||
}
|
||||
|
||||
/// The body of [`Filesystem::unlink`], run off the dispatch loop.
|
||||
fn serve_unlink(&self, parent: u64, name_str: &str, reply: ReplyEmpty) {
|
||||
if let Err(error) = self.core.require_writable(parent) {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
if let Ok((ino, _)) = self.core.lookup_child(parent, name_str) {
|
||||
let st = self.core.state.lock();
|
||||
if let Some(entry) = st.entries.get(&ino)
|
||||
@@ -1312,6 +1397,10 @@ impl ProtonFs {
|
||||
|
||||
/// The body of [`Filesystem::rmdir`], run off the dispatch loop.
|
||||
fn serve_rmdir(&self, parent: u64, name_str: &str, reply: ReplyEmpty) {
|
||||
if let Err(error) = self.core.require_writable(parent) {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
if let Ok((ino, _)) = self.core.lookup_child(parent, name_str) {
|
||||
{
|
||||
let st = self.core.state.lock();
|
||||
@@ -1348,10 +1437,18 @@ impl ProtonFs {
|
||||
reply: ReplyEmpty,
|
||||
) {
|
||||
let was_unlinked = unlinked_uid.is_some();
|
||||
if let Some(uid) = unlinked_uid {
|
||||
self.core.discard_queued_ops(&uid);
|
||||
self.core.cache.evict(&uid);
|
||||
self.core.evict_reader(&uid);
|
||||
if let Some(uid) = unlinked_uid
|
||||
&& release_can_discard_unlinked(&self.core.db, &uid)
|
||||
{
|
||||
if let Err(error) = self.core.discard_queued_ops(&uid) {
|
||||
warn!(%uid, ?error, "queued-op cleanup failed; retaining unlinked state");
|
||||
} else {
|
||||
if let Err(error) = self.core.db.delete_node(&uid) {
|
||||
warn!(%uid, %error, "deleting released unlinked node state failed");
|
||||
}
|
||||
self.core.cache.evict(&uid);
|
||||
self.core.evict_reader(&uid);
|
||||
}
|
||||
}
|
||||
// Hand the bytes to the queue rather than uploading them here: the
|
||||
// scratch file is the only copy of what was just written, and blocking
|
||||
@@ -1366,6 +1463,7 @@ impl ProtonFs {
|
||||
{
|
||||
warn!(path = %h.path.display(), error = %e, "discarding unlinked scratch file failed");
|
||||
}
|
||||
self.core.cache.clear_scratch_durable(&h.path);
|
||||
reply.ok();
|
||||
}
|
||||
(Some(h), false) => match self.core.queue_revision(&h) {
|
||||
@@ -1386,6 +1484,14 @@ impl ProtonFs {
|
||||
flags: RenameFlags,
|
||||
reply: ReplyEmpty,
|
||||
) {
|
||||
if let Err(error) = self
|
||||
.core
|
||||
.require_writable(parent)
|
||||
.and_then(|()| self.core.require_writable(newparent))
|
||||
{
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
let (ino, uid) = match self.core.lookup_child(parent, name) {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
@@ -1413,11 +1519,11 @@ impl ProtonFs {
|
||||
reply.error(e);
|
||||
return;
|
||||
}
|
||||
let new_parent_uid = {
|
||||
let (old_parent_uid, new_parent_uid) = {
|
||||
let st = self.core.state.lock();
|
||||
match st.entries.get(&newparent) {
|
||||
Some(e) => e.uid.clone(),
|
||||
None => {
|
||||
match (st.entries.get(&parent), st.entries.get(&newparent)) {
|
||||
(Some(old), Some(new)) => (old.uid.clone(), new.uid.clone()),
|
||||
_ => {
|
||||
reply.error(Errno::ENOENT);
|
||||
return;
|
||||
}
|
||||
@@ -1446,7 +1552,7 @@ impl ProtonFs {
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some((victim_ino, victim_uid)) = &victim {
|
||||
let replacement_authorized = if let Some((victim_ino, victim_uid)) = &victim {
|
||||
if flags.contains(RenameFlags::RENAME_NOREPLACE) {
|
||||
reply.error(Errno::EEXIST);
|
||||
return;
|
||||
@@ -1490,11 +1596,23 @@ impl ProtonFs {
|
||||
reply.error(e);
|
||||
return;
|
||||
}
|
||||
if let Err(error) = require_rename_access(
|
||||
|authority| self.core.require_uid_writable(authority),
|
||||
&uid,
|
||||
&old_parent_uid,
|
||||
&new_parent_uid,
|
||||
) {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
if let Err(e) = self.core.remove_replaced(victim_uid, newname) {
|
||||
reply.error(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
// A node whose own creation is still queued has no server-side identity
|
||||
// to rename: the queued op *is* the node, so rewriting its target is the
|
||||
// whole rename. Nothing reaches the API, which is why this works offline
|
||||
@@ -1557,10 +1675,26 @@ impl ProtonFs {
|
||||
newparent != parent,
|
||||
newname != name,
|
||||
) {
|
||||
match self
|
||||
.core
|
||||
.queue_rename(ino, &uid, newparent, &new_parent_uid, newname)
|
||||
{
|
||||
let queued = if replacement_authorized {
|
||||
self.core.queue_rename_authorized(
|
||||
ino,
|
||||
&uid,
|
||||
&old_parent_uid,
|
||||
newparent,
|
||||
&new_parent_uid,
|
||||
newname,
|
||||
)
|
||||
} else {
|
||||
self.core.queue_rename(
|
||||
ino,
|
||||
&uid,
|
||||
&old_parent_uid,
|
||||
newparent,
|
||||
&new_parent_uid,
|
||||
newname,
|
||||
)
|
||||
};
|
||||
match queued {
|
||||
Ok(()) => {
|
||||
// Send the rename response first. Kernel-side rename cache
|
||||
// updates happen while processing that response; notifying
|
||||
@@ -1588,6 +1722,16 @@ impl ProtonFs {
|
||||
// A failure past this point has already trashed any node the rename was
|
||||
// replacing, so put it back rather than leaving the caller with neither
|
||||
// the source moved nor the destination intact.
|
||||
if let Err(error) = require_rename_access(
|
||||
|authority| self.core.require_uid_writable(authority),
|
||||
&uid,
|
||||
&old_parent_uid,
|
||||
&new_parent_uid,
|
||||
) {
|
||||
self.core.restore_replaced(victim.as_ref(), newname);
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
if newname != name
|
||||
&& let Err(e) = self
|
||||
.core
|
||||
@@ -1655,7 +1799,7 @@ impl ProtonFs {
|
||||
let st = self.core.state.lock();
|
||||
match st.entries.get(&ino) {
|
||||
Some(e) => {
|
||||
let attr = self.attr(ino, &e.node);
|
||||
let attr = self.attr(ino, &e.node, e.access);
|
||||
reply.attr(&TTL, &attr);
|
||||
}
|
||||
None => reply.error(Errno::ENOENT),
|
||||
@@ -1663,6 +1807,10 @@ impl ProtonFs {
|
||||
}
|
||||
|
||||
fn trash_child(&self, parent: u64, name: &str, reply: ReplyEmpty) {
|
||||
if let Err(error) = self.core.require_writable(parent) {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
let (ino, uid) = match self.core.lookup_child(parent, name) {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
@@ -1681,8 +1829,9 @@ impl ProtonFs {
|
||||
// it just means its queued creation is no longer wanted. This works
|
||||
// offline, which the remote path below cannot (offline.md Phase 3b).
|
||||
if is_local_uid(&uid) {
|
||||
if !open_now {
|
||||
self.core.discard_queued_ops(&uid);
|
||||
if !open_now && let Err(error) = self.core.discard_queued_ops(&uid) {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
self.core.state.lock().forget_or_unlink(&uid);
|
||||
debug!(%uid, name, "deleted a node that had not been created remotely yet");
|
||||
@@ -1694,18 +1843,20 @@ impl ProtonFs {
|
||||
// command returns (offline.md Phase 3b).
|
||||
if !self.core.online.load(Ordering::Relaxed) {
|
||||
match self.core.queue_trash(&uid, name) {
|
||||
Ok(()) => {
|
||||
self.core.state.lock().forget_or_unlink(&uid);
|
||||
reply.ok();
|
||||
}
|
||||
Ok(()) => reply.ok(),
|
||||
Err(e) => reply.error(e),
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Err(e) = self.core.require_uid_writable(&uid) {
|
||||
reply.error(e);
|
||||
return;
|
||||
}
|
||||
if let Err(e) = self
|
||||
.core
|
||||
.rt
|
||||
.block_on(self.core.client.trash_nodes(std::slice::from_ref(&uid)))
|
||||
.and_then(batch::into_unit)
|
||||
{
|
||||
error!(%uid, error = %e, "trash failed");
|
||||
self.core
|
||||
@@ -1718,7 +1869,9 @@ impl ProtonFs {
|
||||
self.core.hidden.lock().insert(uid.clone());
|
||||
self.core.state.lock().forget_or_unlink(&uid);
|
||||
if !open_now {
|
||||
self.core.discard_queued_ops(&uid);
|
||||
if let Err(error) = self.core.discard_queued_ops(&uid) {
|
||||
warn!(%uid, ?error, "remote trash landed but queued-op cleanup failed");
|
||||
}
|
||||
self.core.cache.evict(&uid);
|
||||
self.core.evict_reader(&uid);
|
||||
}
|
||||
|
||||
+2297
-119
File diff suppressed because it is too large.
Load diff
+291
-56
@@ -28,10 +28,101 @@ pub(crate) fn is_stale_mount(path: &Path) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// A secondary (on-demand sync folder) FUSE session, paired with its FUSE
|
||||
/// connection id so teardown can abort a mid-transfer connection rather than
|
||||
/// block on it. See [`Core::mounts`] and [`umount_session_unblocked`].
|
||||
pub(super) type SecondaryMount = (BackgroundSession, Option<u32>);
|
||||
/// A secondary (on-demand sync folder) FUSE session and the exact liveness flag
|
||||
/// published with its forked inode state.
|
||||
///
|
||||
/// Keeping these together makes every teardown mark the fork unroutable before
|
||||
/// unmounting, including paths that can block on an in-flight FUSE request.
|
||||
pub(super) struct SecondaryMount {
|
||||
session: BackgroundSession,
|
||||
conn: Option<u32>,
|
||||
session_live: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum SecondaryInsertRejection {
|
||||
Closed,
|
||||
Duplicate,
|
||||
}
|
||||
|
||||
/// Secondary sessions and the daemon-wide gate controlling whether newly
|
||||
/// spawned sessions may become visible. The gate and map share one lock so
|
||||
/// shutdown cannot race an insertion between checking and publishing.
|
||||
pub(super) struct SecondaryMountRegistry<T> {
|
||||
accepting: bool,
|
||||
entries: HashMap<i64, T>,
|
||||
}
|
||||
|
||||
impl<T> Default for SecondaryMountRegistry<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
accepting: false,
|
||||
entries: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> SecondaryMountRegistry<T> {
|
||||
pub(super) fn open(&mut self) {
|
||||
self.accepting = true;
|
||||
}
|
||||
|
||||
pub(super) fn is_accepting(&self) -> bool {
|
||||
self.accepting
|
||||
}
|
||||
|
||||
pub(super) fn close(&mut self) {
|
||||
self.accepting = false;
|
||||
}
|
||||
|
||||
pub(super) fn insert(
|
||||
&mut self,
|
||||
id: i64,
|
||||
mount: T,
|
||||
) -> Result<(), (SecondaryInsertRejection, T)> {
|
||||
if !self.accepting {
|
||||
return Err((SecondaryInsertRejection::Closed, mount));
|
||||
}
|
||||
if self.entries.contains_key(&id) {
|
||||
return Err((SecondaryInsertRejection::Duplicate, mount));
|
||||
}
|
||||
self.entries.insert(id, mount);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn remove(&mut self, id: &i64) -> Option<T> {
|
||||
self.entries.remove(id)
|
||||
}
|
||||
|
||||
pub(super) fn contains_key(&self, id: &i64) -> bool {
|
||||
self.entries.contains_key(id)
|
||||
}
|
||||
|
||||
pub(super) fn close_and_drain(&mut self) -> Vec<(i64, T)> {
|
||||
self.close();
|
||||
self.entries.drain().collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl SecondaryMount {
|
||||
pub(super) fn new(
|
||||
session: BackgroundSession,
|
||||
conn: Option<u32>,
|
||||
session_live: Arc<AtomicBool>,
|
||||
) -> Self {
|
||||
Self {
|
||||
session,
|
||||
conn,
|
||||
session_live,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn teardown(self) -> std::io::Result<()> {
|
||||
teardown_session(&self.session_live, || {
|
||||
umount_session_unblocked(self.session, self.conn)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The kernel's id for the FUSE connection backing the mount at `mountpoint` —
|
||||
/// the directory name under `/sys/fs/fuse/connections`, which is the minor
|
||||
@@ -152,6 +243,71 @@ pub struct MountOptions {
|
||||
pub sweep_mode: SweepMode,
|
||||
}
|
||||
|
||||
/// Spawn one FUSE session rooted at an arbitrary remote node.
|
||||
///
|
||||
/// Primary My Files and secondary on-demand device locations use this same
|
||||
/// construction path so mount options, stale-mount recovery, registry
|
||||
/// liveness, and notifier ownership cannot drift apart. Each caller owns its
|
||||
/// state/path registration and performs it exactly once before calling here.
|
||||
pub(super) fn spawn_session(
|
||||
core: &Core,
|
||||
mountpoint: &Path,
|
||||
root: Node,
|
||||
) -> std::io::Result<BackgroundSession> {
|
||||
let mut config = Config::default();
|
||||
config.mount_options = vec![
|
||||
MountOption::FSName("protondrive".to_string()),
|
||||
MountOption::Subtype("protondrive".to_string()),
|
||||
MountOption::DefaultPermissions,
|
||||
];
|
||||
|
||||
clear_stale_mount(mountpoint);
|
||||
info!(mountpoint = %mountpoint.display(), "mounting Proton Drive location");
|
||||
let fs = ProtonFs::new(core.clone(), root);
|
||||
if core.primary {
|
||||
// Only My Files rehydrates the daemon-wide node cache. A secondary
|
||||
// inode space is rooted at one device subtree and must not import every
|
||||
// unrelated persisted node.
|
||||
core.hydrate();
|
||||
}
|
||||
let session = Session::new(fs, mountpoint, &config)?.spawn()?;
|
||||
let _ = core.notifier.set(session.notifier());
|
||||
core.session_live.store(true, Ordering::Release);
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Make a session unroutable before beginning any teardown that may block.
|
||||
fn teardown_session<T>(session_live: &AtomicBool, teardown: impl FnOnce() -> T) -> T {
|
||||
session_live.store(false, Ordering::Release);
|
||||
teardown()
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Resolve the primary share id away from startup and publish it only if the
|
||||
/// projected root has not changed while the request was in flight.
|
||||
pub(crate) fn repair_primary_share_id(&self, root_uid: &NodeUid) {
|
||||
match self.rt.block_on(self.client.context_share_id(root_uid)) {
|
||||
Ok(share_id) => {
|
||||
match self
|
||||
.db
|
||||
.mount_repair_my_files_share_id(&root_uid.to_string(), &share_id.to_string())
|
||||
{
|
||||
Ok(true) => debug!(%root_uid, "repaired My Files share id"),
|
||||
Ok(false) => {
|
||||
debug!(%root_uid, "discarded stale My Files share-id result");
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(%root_uid, error = %error, "persist My Files share id failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
debug!(%root_uid, error = %error, "resolve My Files share id failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mount the filesystem at `mountpoint` and block until it is unmounted or the
|
||||
/// daemon is asked to stop.
|
||||
///
|
||||
@@ -179,6 +335,11 @@ pub fn mount(
|
||||
} = options;
|
||||
let (root, online) = fetch_or_recall_root(&client, &rt, &db)?;
|
||||
let scope = root.tree_event_scope_id();
|
||||
db.mount_upsert_my_files(&mountpoint.to_string_lossy(), &root.uid.to_string(), None)
|
||||
.map_err(|error| std::io::Error::other(format!("project My Files mount: {error}")))?;
|
||||
let share_access = db
|
||||
.all_share_access()
|
||||
.map_err(|error| std::io::Error::other(format!("load shared access: {error}")))?;
|
||||
|
||||
// The folder-sync engine (devices.md Phase 2) runs on its own thread and is
|
||||
// nudged over this channel; the sender lives in Core so control-socket
|
||||
@@ -188,6 +349,8 @@ pub fn mount(
|
||||
let core = Core {
|
||||
client: client.clone(),
|
||||
rt: rt.clone(),
|
||||
primary_root_uid: root.uid.clone(),
|
||||
primary: true,
|
||||
state: Arc::new(Mutex::new(State {
|
||||
entries: HashMap::new(),
|
||||
by_uid: HashMap::new(),
|
||||
@@ -196,6 +359,8 @@ pub fn mount(
|
||||
active_writes: HashMap::new(),
|
||||
handles: HashMap::new(),
|
||||
next_fh: 1,
|
||||
access_changes: HashSet::new(),
|
||||
share_access,
|
||||
db: db.clone(),
|
||||
})),
|
||||
cache: Arc::new(cache),
|
||||
@@ -203,12 +368,17 @@ pub fn mount(
|
||||
stream_ring: Arc::new(Mutex::new(StreamRing::default())),
|
||||
workers: Arc::new(Workers::new(FUSE_WORKERS)?),
|
||||
db,
|
||||
shared_publication: Arc::new(Mutex::new(())),
|
||||
shared_generation: Arc::new(AtomicU64::new(0)),
|
||||
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
||||
online: Arc::new(AtomicBool::new(online)),
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
hidden: Arc::new(Mutex::new(HashSet::new())),
|
||||
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
||||
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
||||
albums_refreshing: Arc::new(AtomicBool::new(false)),
|
||||
trash_refreshing: Arc::new(AtomicBool::new(false)),
|
||||
trash_progress: Arc::new(tokio::sync::Notify::new()),
|
||||
conflict_notified: Arc::new(Mutex::new(HashSet::new())),
|
||||
sweep_mode,
|
||||
own_sealed_revs: Arc::new(Mutex::new(HashMap::new())),
|
||||
@@ -217,17 +387,18 @@ pub fn mount(
|
||||
no_thumbnail: Arc::new(Mutex::new(HashMap::new())),
|
||||
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
|
||||
notifier: Arc::new(OnceLock::new()),
|
||||
session_live: Arc::new(AtomicBool::new(false)),
|
||||
transfers: TransferRegistry::new(),
|
||||
indexing: Arc::new(AtomicBool::new(false)),
|
||||
sync_progress: Arc::new(Mutex::new(HashMap::new())),
|
||||
sync_tx,
|
||||
mounts: Arc::new(Mutex::new(HashMap::new())),
|
||||
mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
|
||||
sync_locks: Arc::new(Mutex::new(HashMap::new())),
|
||||
states: Arc::new(StateRegistry::default()),
|
||||
};
|
||||
// Before anything can queue work against it: the drain thread below reaches
|
||||
// every mount's inode space through this registry, not through `core.state`.
|
||||
core.register_state();
|
||||
core.register_state(mountpoint);
|
||||
|
||||
// Writes queued by a previous run (or left behind by a crash) are still owed
|
||||
// an upload, and reads must be served from their staged blobs until they land.
|
||||
@@ -271,16 +442,6 @@ pub fn mount(
|
||||
.spawn(move || core.run_online_probe())?;
|
||||
}
|
||||
|
||||
// Re-establish on-demand mounts left over from a previous run (devices.md
|
||||
// Phase 4). On its own thread: each remount fetches a remote node, and we
|
||||
// must not block the main mount below on the network.
|
||||
{
|
||||
let core = core.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-restore-ondemand".into())
|
||||
.spawn(move || core.restore_ondemand_mounts())?;
|
||||
}
|
||||
|
||||
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
|
||||
// the walk is I/O-heavy and must never sit in front of a FUSE callback.
|
||||
{
|
||||
@@ -293,18 +454,6 @@ pub fn mount(
|
||||
.spawn(move || run_local_index(db, indexing, transfers, mountpoint))?;
|
||||
}
|
||||
|
||||
let mut config = Config::default();
|
||||
config.mount_options = vec![
|
||||
MountOption::FSName("protondrive".to_string()),
|
||||
MountOption::Subtype("protondrive".to_string()),
|
||||
MountOption::DefaultPermissions,
|
||||
];
|
||||
|
||||
// A crashed previous run can leave the kernel mount in place, which makes
|
||||
// the fresh mount below fail with EBUSY. Lazily detach any leftover first.
|
||||
clear_stale_mount(mountpoint);
|
||||
info!(mountpoint = %mountpoint.display(), "mounting Proton Drive");
|
||||
|
||||
// Bind the control socket before the FUSE session takes over the thread. A
|
||||
// stale socket file from a previous run would block the bind, so clear it.
|
||||
let _ = std::fs::remove_file(control_socket);
|
||||
@@ -321,32 +470,70 @@ pub fn mount(
|
||||
"control socket permissions: {e}"
|
||||
)));
|
||||
}
|
||||
{
|
||||
let core = core.clone();
|
||||
let username = username.clone();
|
||||
let mountpoint = mountpoint.to_path_buf();
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-control".into())
|
||||
.spawn(move || run_control_socket(core, username, mountpoint, listener))?;
|
||||
}
|
||||
|
||||
let fs = ProtonFs::new(core.clone(), root);
|
||||
// Warm the in-memory maps from the DB so a cold start serves previously
|
||||
// seen metadata without re-hitting the API (plan.md P1).
|
||||
core.hydrate();
|
||||
|
||||
// Build the session explicitly (not `mount2`) so we can grab a `Notifier`
|
||||
// for the event task. `spawn` runs the session loop on its own background
|
||||
// thread; we then wait here for either a stop signal or the mount ending.
|
||||
let bg = Session::new(fs, mountpoint, &config)?.spawn()?;
|
||||
// Do not accept control requests until `spawn_session` has transitioned the
|
||||
// primary registration to live. The listener may queue a connection during
|
||||
// this short window, but its handler cannot observe `mounted = false`.
|
||||
let bg = match spawn_session(&core, mountpoint, root) {
|
||||
Ok(session) => session,
|
||||
Err(error) => {
|
||||
let _ = std::fs::remove_file(control_socket);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
core.mounts.lock().open();
|
||||
// The connection id, captured now while the mount is live, so a stop signal
|
||||
// mid-transfer can abort it rather than block `join` (see `abort_fuse_connection`).
|
||||
let main_conn = fuse_connection_id(mountpoint);
|
||||
let notifier = bg.notifier();
|
||||
// Same channel, kept on the `Core` so background work (a size upgrade, say)
|
||||
// can invalidate kernel-cached metadata without threading a handle through.
|
||||
let _ = core.notifier.set(notifier.clone());
|
||||
{
|
||||
let control_core = core.clone();
|
||||
let username = username.clone();
|
||||
let mountpoint = mountpoint.to_path_buf();
|
||||
if let Err(error) = std::thread::Builder::new()
|
||||
.name("pdfs-control".into())
|
||||
.spawn(move || run_control_socket(control_core, username, mountpoint, listener))
|
||||
{
|
||||
let secondaries = core.mounts.lock().close_and_drain();
|
||||
for (id, mount) in secondaries {
|
||||
if let Err(unmount_error) = mount.teardown() {
|
||||
warn!(id, error = %unmount_error, "secondary teardown after control startup failure failed");
|
||||
}
|
||||
}
|
||||
if let Err(unmount_error) = teardown_session(&core.session_live, || {
|
||||
umount_session_unblocked(bg, main_conn)
|
||||
}) {
|
||||
warn!(error = %unmount_error, "unmount after control startup failure failed");
|
||||
}
|
||||
let _ = std::fs::remove_file(control_socket);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
// Re-establish on-demand mounts only after the primary session is live.
|
||||
// Keep the worker so shutdown can close publication, wait for any in-flight
|
||||
// fetch to observe that closure, and then drain a stable registry.
|
||||
let restore_worker = {
|
||||
let restore_core = core.clone();
|
||||
match std::thread::Builder::new()
|
||||
.name("pdfs-restore-ondemand".into())
|
||||
.spawn(move || restore_core.restore_ondemand_mounts())
|
||||
{
|
||||
Ok(worker) => Some(worker),
|
||||
Err(error) => {
|
||||
warn!(%error, "start on-demand restore worker failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
rt.spawn(run_event_sync(client, scope, core.clone()));
|
||||
if online {
|
||||
let repair_core = core.clone();
|
||||
let root_uid = core.primary_root_uid.clone();
|
||||
if let Err(error) = std::thread::Builder::new()
|
||||
.name("pdfs-share-id".into())
|
||||
.spawn(move || repair_core.repair_primary_share_id(&root_uid))
|
||||
{
|
||||
warn!(%error, "start My Files share-id repair failed");
|
||||
}
|
||||
}
|
||||
|
||||
// Stop signals (SIGTERM from `systemctl --user stop`, SIGINT from Ctrl-C)
|
||||
// are delivered onto the async runtime; bridge them onto a sync channel so
|
||||
@@ -385,7 +572,10 @@ pub fn mount(
|
||||
match sig_rx.recv_timeout(Duration::from_millis(500)) {
|
||||
Ok(()) => {
|
||||
info!("stop requested; unmounting");
|
||||
if let Err(e) = umount_session_unblocked(bg, main_conn) {
|
||||
core.mounts.lock().close();
|
||||
if let Err(e) = teardown_session(&core.session_live, || {
|
||||
umount_session_unblocked(bg, main_conn)
|
||||
}) {
|
||||
warn!(error = %e, "umount_and_join failed");
|
||||
}
|
||||
break MountOutcome::Shutdown;
|
||||
@@ -393,7 +583,8 @@ pub fn mount(
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
if bg.guard.is_finished() {
|
||||
info!("mount ended externally");
|
||||
if let Err(e) = bg.join() {
|
||||
core.mounts.lock().close();
|
||||
if let Err(e) = teardown_session(&core.session_live, || bg.join()) {
|
||||
warn!(error = %e, "session join failed");
|
||||
}
|
||||
break MountOutcome::Unmounted;
|
||||
@@ -401,7 +592,8 @@ pub fn mount(
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||
// Signal task gone (failed to install); fall back to join.
|
||||
let _ = bg.join();
|
||||
core.mounts.lock().close();
|
||||
let _ = teardown_session(&core.session_live, || bg.join());
|
||||
break MountOutcome::Unmounted;
|
||||
}
|
||||
}
|
||||
@@ -409,9 +601,14 @@ pub fn mount(
|
||||
|
||||
// Unmount every on-demand sync folder too, or the kernel mounts linger as
|
||||
// stale and the next start fails with EBUSY (devices.md Phase 3).
|
||||
let secondaries: Vec<_> = core.mounts.lock().drain().collect();
|
||||
for (id, (session, conn)) in secondaries {
|
||||
if let Err(e) = umount_session_unblocked(session, conn) {
|
||||
if let Some(worker) = restore_worker
|
||||
&& worker.join().is_err()
|
||||
{
|
||||
warn!("on-demand restore worker panicked");
|
||||
}
|
||||
let secondaries = core.mounts.lock().close_and_drain();
|
||||
for (id, mount) in secondaries {
|
||||
if let Err(e) = mount.teardown() {
|
||||
warn!(id, error = %e, "unmount on-demand folder failed");
|
||||
}
|
||||
}
|
||||
@@ -419,3 +616,41 @@ pub fn mount(
|
||||
let _ = std::fs::remove_file(control_socket);
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use super::{SecondaryInsertRejection, SecondaryMountRegistry, teardown_session};
|
||||
|
||||
#[test]
|
||||
fn session_is_unroutable_before_teardown_starts() {
|
||||
let live = AtomicBool::new(true);
|
||||
teardown_session(&live, || {
|
||||
assert!(
|
||||
!live.load(Ordering::Acquire),
|
||||
"teardown must never run while the registry still reports mounted"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secondary_registry_rejects_closed_and_duplicate_insertions() {
|
||||
let mut mounts = SecondaryMountRegistry::default();
|
||||
assert_eq!(
|
||||
mounts.insert(7, "closed"),
|
||||
Err((SecondaryInsertRejection::Closed, "closed"))
|
||||
);
|
||||
|
||||
mounts.open();
|
||||
assert_eq!(mounts.insert(7, "first"), Ok(()));
|
||||
assert_eq!(
|
||||
mounts.insert(7, "duplicate"),
|
||||
Err((SecondaryInsertRejection::Duplicate, "duplicate"))
|
||||
);
|
||||
assert!(mounts.contains_key(&7));
|
||||
|
||||
assert_eq!(mounts.close_and_drain(), vec![(7, "first")]);
|
||||
assert!(!mounts.is_accepting());
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
//! user. Thumbnails the server has none for (anything a camera wrote rather than
|
||||
//! a phone) are generated locally and stored as if the server had served them.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use pdfs_core::control::{PhotoItem, PhotoKind, PhotoThumb};
|
||||
@@ -14,7 +14,7 @@ use pdfs_core::{CoreError, CoreResult};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use proton_drive_rs::{NodeKind, ThumbnailType};
|
||||
use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, ThumbnailType};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::{
|
||||
@@ -97,6 +97,7 @@ impl Core {
|
||||
limit: usize,
|
||||
kind: Option<PhotoKind>,
|
||||
range: Option<(i64, i64)>,
|
||||
favorites: bool,
|
||||
) -> CoreResult<Option<Vec<PhotoItem>>> {
|
||||
let count = self.db.photos_count().map_err(CoreError::from)?;
|
||||
if count == 0 {
|
||||
@@ -116,7 +117,7 @@ impl Core {
|
||||
|
||||
let page = self
|
||||
.db
|
||||
.photos_page(offset, limit, kind, range)
|
||||
.photos_page(offset, limit, kind, range, favorites)
|
||||
.map_err(CoreError::from)?;
|
||||
Ok(Some(page.into_iter().map(|p| self.photo_item(p)).collect()))
|
||||
}
|
||||
@@ -139,9 +140,42 @@ impl Core {
|
||||
ratio: photo.ratio,
|
||||
no_thumb: photo.thumb_state == db::THUMB_NONE,
|
||||
kind: photo.kind,
|
||||
favorite: photo.favorite,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add or remove Proton's `Favorite` tag on a photo, then record the change
|
||||
/// locally so the gallery reflects it without waiting for a timeline refresh.
|
||||
///
|
||||
/// The SDK reports one outcome per photo rather than failing the call, so the
|
||||
/// single outcome is unwrapped back into this request's result.
|
||||
pub(crate) fn set_photo_favorite(&self, uid: &NodeUid, favorite: bool) -> CoreResult<()> {
|
||||
let photos = self.photos();
|
||||
let update = PhotoTagsUpdate {
|
||||
node_uid: uid.clone(),
|
||||
tags_to_add: if favorite {
|
||||
vec![PhotoTag::Favorite]
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
tags_to_remove: if favorite {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![PhotoTag::Favorite]
|
||||
},
|
||||
};
|
||||
let outcomes = self
|
||||
.rt
|
||||
.block_on(photos.update_photos(std::slice::from_ref(&update)))
|
||||
.map_err(|e| CoreError::from_api(&e, "update photo tags"))?;
|
||||
pdfs_core::batch::into_unit(outcomes)
|
||||
.map_err(|e| CoreError::from_api(&e, "update photo tags"))?;
|
||||
self.db
|
||||
.photos_set_favorite(&uid.to_string(), favorite)
|
||||
.map_err(CoreError::from)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Thumbnails for `uids`, served from the cache, fetched from the server for
|
||||
/// whatever is missing, and — for the photos the server has no thumbnail for
|
||||
/// at all — *generated locally* from the full file (see
|
||||
@@ -154,7 +188,20 @@ impl Core {
|
||||
pub(crate) fn photo_thumbs(&self, uids: &[NodeUid]) -> Vec<PhotoThumb> {
|
||||
let ttype = ThumbnailType::Thumbnail.as_i32();
|
||||
let keys: Vec<String> = uids.iter().map(|u| u.to_string()).collect();
|
||||
let stored = self.db.photos_by_uid(&keys).unwrap_or_default();
|
||||
let mut stored = self.db.photos_by_uid(&keys).unwrap_or_default();
|
||||
// Album covers and the contents of an album shared with us are not in our
|
||||
// own timeline, so their capture time — the cache's validity tag — only
|
||||
// exists on the album rows. Fill in whatever the timeline didn't cover.
|
||||
{
|
||||
let have: HashSet<String> = stored.iter().map(|p| p.uid.clone()).collect();
|
||||
let missing: Vec<String> = keys
|
||||
.iter()
|
||||
.filter(|k| !have.contains(*k))
|
||||
.cloned()
|
||||
.collect();
|
||||
stored.extend(self.db.album_photos_by_uid(&missing).unwrap_or_default());
|
||||
}
|
||||
let stored = stored;
|
||||
let tags: HashMap<String, i64> = stored
|
||||
.iter()
|
||||
.map(|p| (p.uid.clone(), p.capture_time))
|
||||
@@ -329,11 +376,17 @@ impl Core {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist what a thumbnail attempt learned about a photo.
|
||||
/// Persist what a thumbnail attempt learned about a photo, against the
|
||||
/// timeline and against every album the photo sits in — one attempt, one
|
||||
/// verdict, wherever the photo is painted.
|
||||
pub(crate) fn record_thumb(&self, uid: &NodeUid, state: i64, ratio: Option<f64>) {
|
||||
if let Err(e) = self.db.photo_set_thumb(&uid.to_string(), state, ratio) {
|
||||
let key = uid.to_string();
|
||||
if let Err(e) = self.db.photo_set_thumb(&key, state, ratio) {
|
||||
warn!(%uid, error = %e, "recording thumbnail state failed");
|
||||
}
|
||||
if let Err(e) = self.db.album_photo_set_thumb(&key, state, ratio) {
|
||||
warn!(%uid, error = %e, "recording album thumbnail state failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the listing stamped under `key` is older than `ttl` (or was never
|
||||
@@ -362,7 +415,7 @@ impl Core {
|
||||
// learned before (or classifies from nothing, i.e. a still photo), so a
|
||||
// partial resolve never blanks the timeline.
|
||||
let uids: Vec<NodeUid> = items.iter().map(|it| it.uid.clone()).collect();
|
||||
let mut meta: HashMap<String, (Option<String>, Option<String>)> = HashMap::new();
|
||||
let mut meta: HashMap<String, (Option<String>, Option<String>, bool)> = HashMap::new();
|
||||
for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) {
|
||||
match photos.enumerate_nodes(chunk).await {
|
||||
Ok(nodes) => {
|
||||
@@ -371,19 +424,35 @@ impl Core {
|
||||
NodeKind::File { media_type, .. } => Some(media_type.clone()),
|
||||
NodeKind::Folder => None,
|
||||
};
|
||||
meta.insert(node.uid.to_string(), (Some(node.name), media_type));
|
||||
// The favourite state is a tag on the photo node, so it
|
||||
// rides along with the metadata resolve rather than
|
||||
// costing a listing of its own.
|
||||
let favorite = node
|
||||
.photo
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.tags.contains(&PhotoTag::Favorite));
|
||||
meta.insert(
|
||||
node.uid.to_string(),
|
||||
(Some(node.name), media_type, favorite),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(error = %e, "resolving photo metadata for a timeline chunk failed"),
|
||||
}
|
||||
}
|
||||
|
||||
let rows: Vec<(String, i64, Option<String>, Option<String>)> = items
|
||||
let rows: Vec<db::TimelineRow> = items
|
||||
.iter()
|
||||
.map(|it| {
|
||||
let key = it.uid.to_string();
|
||||
let (name, media_type) = meta.get(&key).cloned().unwrap_or((None, None));
|
||||
(key, it.capture_time, name, media_type)
|
||||
// An unresolved photo keeps whatever was learned before, for the
|
||||
// favourite flag as much as for the name and media type.
|
||||
match meta.get(&key).cloned() {
|
||||
Some((name, media_type, favorite)) => {
|
||||
(key, it.capture_time, name, media_type, Some(favorite))
|
||||
}
|
||||
None => (key, it.capture_time, None, None, None),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.db.photos_replace(&rows).map_err(CoreError::from)?;
|
||||
|
||||
+164
-21
@@ -12,7 +12,10 @@ use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use pdfs_core::control::RestorableFolder;
|
||||
use pdfs_core::profile::{PROFILE_FILE_NAME, PROFILE_VERSION, Profile, ProfileFolder, ProfilePin};
|
||||
use pdfs_core::mounts::MountMode;
|
||||
use pdfs_core::profile::{
|
||||
PROFILE_DIR_NAME, PROFILE_FILE_NAME, PROFILE_VERSION, Profile, ProfileFolder, ProfilePin,
|
||||
};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use tracing::{info, warn};
|
||||
@@ -63,8 +66,9 @@ impl Core {
|
||||
});
|
||||
}
|
||||
|
||||
/// Serialize this machine's arrangement and upload it to `profile.json` in
|
||||
/// the device root, replacing any existing one.
|
||||
/// Serialize this machine's arrangement and upload it to `profile.json`
|
||||
/// inside the device's `.proton-drive-linux` folder, replacing any existing
|
||||
/// one.
|
||||
///
|
||||
/// Does nothing when no device is registered: a machine that has never
|
||||
/// synced a folder has nothing to describe, and registering a device just to
|
||||
@@ -85,11 +89,15 @@ impl Core {
|
||||
let bytes = profile.to_bytes().map_err(CoreError::internal)?;
|
||||
let len = bytes.len() as i64;
|
||||
|
||||
// The device root itself rejects file creation (B78), so the profile
|
||||
// lives one level down, in a folder created on first save.
|
||||
let dir_uid = self.ensure_profile_dir(&root_uid)?;
|
||||
|
||||
// Replace the existing document rather than adding a second file of the
|
||||
// same name: the profile is a single mutable record, and a device root
|
||||
// with `profile.json` and `profile (1).json` in it teaches the restore
|
||||
// same name: the profile is a single mutable record, and a folder with
|
||||
// `profile.json` and `profile (1).json` in it teaches the restore
|
||||
// nothing about which to trust.
|
||||
let existing = self.find_device_child_file(&root_uid, PROFILE_FILE_NAME)?;
|
||||
let existing = self.find_device_child_file(&dir_uid, PROFILE_FILE_NAME)?;
|
||||
match existing {
|
||||
Some(uid) => self
|
||||
.rt
|
||||
@@ -105,7 +113,7 @@ impl Core {
|
||||
None => {
|
||||
self.rt
|
||||
.block_on(self.client.upload_file_replacing_draft_from(
|
||||
&root_uid,
|
||||
&dir_uid,
|
||||
PROFILE_FILE_NAME,
|
||||
"application/json",
|
||||
Cursor::new(bytes),
|
||||
@@ -133,7 +141,7 @@ impl Core {
|
||||
local_path: f.local_path,
|
||||
// A queued switch is where the user asked the folder to be, so
|
||||
// that is what the next machine should set up.
|
||||
mode: f.pending_mode.unwrap_or(f.mode),
|
||||
mode: MountMode::from(f.pending_mode.unwrap_or(f.mode).as_str()),
|
||||
})
|
||||
.collect();
|
||||
let pins = self
|
||||
@@ -163,18 +171,18 @@ impl Core {
|
||||
})
|
||||
}
|
||||
|
||||
/// An untrashed *file* named `name` directly under the device root.
|
||||
/// An untrashed *file* named `name` directly under `parent_uid`.
|
||||
///
|
||||
/// The sibling of `find_device_child_folder`; kept separate because a folder
|
||||
/// named `profile.json` must not be mistaken for the profile.
|
||||
fn find_device_child_file(
|
||||
&self,
|
||||
root_uid: &NodeUid,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
) -> CoreResult<Option<NodeUid>> {
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_folder_children_node_uids(root_uid))
|
||||
.block_on(self.client.enumerate_folder_children_node_uids(parent_uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
||||
if uids.is_empty() {
|
||||
return Ok(None);
|
||||
@@ -189,10 +197,47 @@ impl Core {
|
||||
.map(|n| n.uid))
|
||||
}
|
||||
|
||||
/// Download and parse the profile in the device root, if there is one.
|
||||
/// The device's `.proton-drive-linux` folder, creating it if this is the
|
||||
/// first save on this device.
|
||||
///
|
||||
/// Reused by stable identity rather than recreated: a second folder of the
|
||||
/// same name would split the profile across two documents, and the restore
|
||||
/// would have no way to tell which is current.
|
||||
fn ensure_profile_dir(&self, root_uid: &NodeUid) -> CoreResult<NodeUid> {
|
||||
if let Some(uid) = self.find_device_child_folder(root_uid, PROFILE_DIR_NAME)? {
|
||||
return Ok(uid);
|
||||
}
|
||||
let uid = self
|
||||
.rt
|
||||
.block_on(
|
||||
self.client
|
||||
.create_folder(root_uid, PROFILE_DIR_NAME, Some(now_secs())),
|
||||
)
|
||||
.map_err(|e| CoreError::from_api(&e, "create profile folder"))?;
|
||||
info!(name = PROFILE_DIR_NAME, "created device profile folder");
|
||||
Ok(uid)
|
||||
}
|
||||
|
||||
/// Download and parse this device's profile, if there is one.
|
||||
///
|
||||
/// Looks in `.proton-drive-linux/` first and falls back to the device root,
|
||||
/// where clients before the B78 fix wrote it. The fallback is read-only: the
|
||||
/// next save writes to the folder, and the legacy document is left alone
|
||||
/// rather than trashed, so an older client on another machine keeps working.
|
||||
fn load_profile(&self, root_uid: &NodeUid) -> CoreResult<Option<Profile>> {
|
||||
let Some(uid) = self.find_device_child_file(root_uid, PROFILE_FILE_NAME)? else {
|
||||
return Ok(None);
|
||||
let current = match self.find_device_child_folder(root_uid, PROFILE_DIR_NAME)? {
|
||||
Some(dir_uid) => self.find_device_child_file(&dir_uid, PROFILE_FILE_NAME)?,
|
||||
None => None,
|
||||
};
|
||||
let uid = match current {
|
||||
Some(uid) => uid,
|
||||
None => match self.find_device_child_file(root_uid, PROFILE_FILE_NAME)? {
|
||||
Some(uid) => {
|
||||
info!("reading legacy profile from the device root");
|
||||
uid
|
||||
}
|
||||
None => return Ok(None),
|
||||
},
|
||||
};
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
self.rt
|
||||
@@ -251,7 +296,10 @@ impl Core {
|
||||
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
.filter(|n| n.is_folder() && !n.trashed)
|
||||
// `.proton-drive-linux` holds the profile itself, not user data:
|
||||
// offering it as a restorable folder would sync the bookkeeping
|
||||
// back onto the machine it describes.
|
||||
.filter(|n| n.is_folder() && !n.trashed && n.name != PROFILE_DIR_NAME)
|
||||
.map(|n| {
|
||||
let uid = n.uid.to_string();
|
||||
let saved = profile
|
||||
@@ -267,7 +315,7 @@ impl Core {
|
||||
name: n.name,
|
||||
local_path: proposed.to_string_lossy().to_string(),
|
||||
mode: saved
|
||||
.map(|f| f.mode.clone())
|
||||
.map(|f| f.mode.to_string())
|
||||
.unwrap_or_else(|| "mirror".into()),
|
||||
}
|
||||
})
|
||||
@@ -319,10 +367,10 @@ impl Core {
|
||||
skipped.push(format!("{}: {e}", item.local_path));
|
||||
continue;
|
||||
}
|
||||
let mode = match item.mode.as_str() {
|
||||
"mirror" | "ondemand" => item.mode.as_str(),
|
||||
other => {
|
||||
skipped.push(format!("{}: unknown mode {other}", item.local_path));
|
||||
let mode = match restore_mode(&item.mode) {
|
||||
Some(mode) => mode,
|
||||
None => {
|
||||
skipped.push(format!("{}: unknown mode {}", item.local_path, item.mode));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -333,7 +381,7 @@ impl Core {
|
||||
// `ondemand` is applied through the normal request path, which
|
||||
// mounts the FUSE session and handles the folder being busy — the
|
||||
// restore does not get its own copy of that logic.
|
||||
if mode == "ondemand" {
|
||||
if mode == MountMode::OnDemand {
|
||||
let _ = self.request_sync_folder_mode(id, "ondemand");
|
||||
}
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
||||
@@ -350,6 +398,17 @@ impl Core {
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept only modes this build can safely restore. `MountMode` itself is
|
||||
/// tolerant so a future value does not reject the whole profile; the folder
|
||||
/// carrying that value is skipped here with the message assembled above.
|
||||
fn restore_mode(value: &str) -> Option<MountMode> {
|
||||
match MountMode::from(value) {
|
||||
MountMode::Mirror => Some(MountMode::Mirror),
|
||||
MountMode::OnDemand => Some(MountMode::OnDemand),
|
||||
MountMode::Unknown => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a path recorded on another machine can be reused verbatim here.
|
||||
///
|
||||
/// Requires the *parent* to exist: `~/Documents` on a machine with the same
|
||||
@@ -382,6 +441,90 @@ mod tests {
|
||||
assert!(!path_is_usable_here(Path::new("relative/path")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_profile_mode_is_skipped_without_rejecting_known_modes() {
|
||||
assert_eq!(restore_mode("mirror"), Some(MountMode::Mirror));
|
||||
assert_eq!(restore_mode("ondemand"), Some(MountMode::OnDemand));
|
||||
assert_eq!(restore_mode("streaming"), None);
|
||||
assert_eq!(restore_mode("unknown"), None);
|
||||
}
|
||||
|
||||
/// The device root refuses file creation (B78), so every upload in
|
||||
/// `save_profile` must be addressed to the profile folder. Checked against
|
||||
/// the source because both branches are pure network calls.
|
||||
#[test]
|
||||
fn the_profile_is_never_uploaded_to_the_device_root() {
|
||||
let source = include_str!("profile.rs");
|
||||
let save = function_source(source, "fn save_profile(");
|
||||
assert!(
|
||||
save.contains("ensure_profile_dir(&root_uid)"),
|
||||
"save_profile resolves the profile folder"
|
||||
);
|
||||
for upload in [
|
||||
"upload_new_revision_from",
|
||||
"upload_file_replacing_draft_from",
|
||||
] {
|
||||
let call = save.find(upload).expect("upload call exists");
|
||||
let args = &save[call..];
|
||||
let root = args.find("&root_uid").unwrap_or(usize::MAX);
|
||||
let dir = args.find("&dir_uid").unwrap_or(usize::MAX);
|
||||
assert!(dir < root, "{upload} must target the profile folder");
|
||||
}
|
||||
}
|
||||
|
||||
/// A read has to keep finding profiles written before the fix, which sat in
|
||||
/// the device root.
|
||||
#[test]
|
||||
fn loading_falls_back_to_a_legacy_device_root_profile() {
|
||||
let source = include_str!("profile.rs");
|
||||
let load = function_source(source, "fn load_profile(");
|
||||
assert!(load.contains("PROFILE_DIR_NAME"), "prefers the folder");
|
||||
assert!(
|
||||
load.find("PROFILE_DIR_NAME").unwrap()
|
||||
< load.find("legacy profile").expect("legacy fallback exists"),
|
||||
"the folder is consulted before the device root"
|
||||
);
|
||||
}
|
||||
|
||||
/// The profile folder is the client's own bookkeeping: neither the restore
|
||||
/// picker nor `add_sync_folder` may treat it as a user folder.
|
||||
#[test]
|
||||
fn the_profile_folder_is_not_offered_as_user_data() {
|
||||
let restorable = function_source(include_str!("profile.rs"), "fn list_restorable_folders(");
|
||||
assert!(
|
||||
restorable.contains("n.name != PROFILE_DIR_NAME"),
|
||||
"restorable folders exclude the profile folder"
|
||||
);
|
||||
let add = function_source(include_str!("devices.rs"), "fn add_sync_folder(");
|
||||
assert_before(add, "PROFILE_DIR_NAME", "ensure_device()");
|
||||
}
|
||||
|
||||
fn function_source<'a>(source: &'a str, signature: &str) -> &'a str {
|
||||
let start = source.find(signature).expect("function signature exists");
|
||||
let body = &source[start..];
|
||||
let open = body.find('{').expect("function body starts");
|
||||
let mut depth = 0usize;
|
||||
for (offset, byte) in body.as_bytes()[open..].iter().enumerate() {
|
||||
match byte {
|
||||
b'{' => depth += 1,
|
||||
b'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return &body[..open + offset + 1];
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
panic!("function body closes")
|
||||
}
|
||||
|
||||
fn assert_before(source: &str, first: &str, second: &str) {
|
||||
let first_at = source.find(first).expect("first operation exists");
|
||||
let second_at = source.find(second).expect("second operation exists");
|
||||
assert!(first_at < second_at, "{first} must precede {second}");
|
||||
}
|
||||
|
||||
/// A path whose parent exists but which does not itself is exactly the
|
||||
/// restore case — the directory is about to be created.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
//! File version history: listing a file's revisions, restoring one, deleting
|
||||
//! one, and writing an old one out to a local path.
|
||||
//!
|
||||
//! Proton Drive keeps every revision a client ever committed, and until now the
|
||||
//! daemon only ever addressed the active one — a file that a sync pass or a
|
||||
//! `(sync-conflict)` fork overwrote could only be recovered from whatever the
|
||||
//! local `recovery/` directory happened to hold. This is the remote equivalent,
|
||||
//! and it is the reason a *restore* here is not a download-then-upload: the
|
||||
//! server re-points the file at an existing revision, so no content crosses the
|
||||
//! wire and nothing lands in the drain queue.
|
||||
//!
|
||||
//! Two things about the SDK surface leak into every method here:
|
||||
//!
|
||||
//! - **A restore is asynchronous.** The server answers 202 and swaps the active
|
||||
//! revision in the background, so the daemon drops its cached content for the
|
||||
//! file rather than trying to describe the new one. The next read repopulates
|
||||
//! from whatever is live by then.
|
||||
//! - **The active revision cannot be deleted**, and the server — not this code —
|
||||
//! is what enforces that. [`Core::delete_revision_for_uid`] refuses locally
|
||||
//! too, so the user gets a sentence instead of an API code.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use pdfs_core::control::{ActivityKind, RevisionInfo};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::Revision;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use tracing::info;
|
||||
|
||||
use super::Core;
|
||||
|
||||
/// A [`Revision`] as the control protocol reports it.
|
||||
fn revision_info(revision: Revision) -> RevisionInfo {
|
||||
RevisionInfo {
|
||||
is_active: revision.is_active(),
|
||||
id: revision.revision_id,
|
||||
created: revision.creation_time,
|
||||
size_on_storage: revision.size_on_storage,
|
||||
claimed_size: revision.claimed_size,
|
||||
claimed_modified: revision.claimed_modification_time,
|
||||
// An empty signer means the node key signed it anonymously, which is not
|
||||
// an address a front-end can show.
|
||||
signed_by: revision.signature_email.filter(|email| !email.is_empty()),
|
||||
has_thumbnails: revision.has_thumbnails,
|
||||
}
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// The version history of the file at mountpoint-relative `rel`.
|
||||
pub(crate) fn list_revisions(&self, rel: &Path) -> CoreResult<Vec<RevisionInfo>> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.list_revisions_for_uid(&uid)
|
||||
}
|
||||
|
||||
/// [`Core::list_revisions`] for a node addressed through any daemon location.
|
||||
pub(crate) fn list_revisions_by_uid(&self, uid: &str) -> CoreResult<Vec<RevisionInfo>> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.list_revisions_for_uid(&uid)
|
||||
}
|
||||
|
||||
fn list_revisions_for_uid(&self, uid: &NodeUid) -> CoreResult<Vec<RevisionInfo>> {
|
||||
let revisions = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_revisions(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list revisions"))?;
|
||||
Ok(revisions.into_iter().map(revision_info).collect())
|
||||
}
|
||||
|
||||
/// Make `revision_id` the current content of the file at `rel`.
|
||||
pub(crate) fn restore_revision(&self, rel: &Path, revision_id: &str) -> CoreResult<()> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
let name = rel
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| uid.to_string());
|
||||
self.restore_revision_for_uid(&uid, revision_id, &name)?;
|
||||
// The size and mtime a listing shows describe the revision that was
|
||||
// active a moment ago.
|
||||
self.invalidate_parent_listing(rel);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`Core::restore_revision`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn restore_revision_by_uid(&self, uid: &str, revision_id: &str) -> CoreResult<()> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
let name = uid.to_string();
|
||||
self.restore_revision_for_uid(&uid, revision_id, &name)
|
||||
}
|
||||
|
||||
fn restore_revision_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
revision_id: &str,
|
||||
name: &str,
|
||||
) -> CoreResult<()> {
|
||||
// Restoring rewrites the file's content: the same authority a write
|
||||
// needs, not the one a read needs.
|
||||
self.require_uid_writable(uid)
|
||||
.map_err(|error| self.errno_error(error, "restore revision"))?;
|
||||
if let Err(e) = self
|
||||
.rt
|
||||
.block_on(self.client.restore_revision(uid, revision_id))
|
||||
{
|
||||
let error = CoreError::from_api(&e, "restore revision");
|
||||
self.log_activity(ActivityKind::Restore, name, &error, false);
|
||||
return Err(error);
|
||||
}
|
||||
// The active revision is changing behind our back — anything describing
|
||||
// the old one (cached blocks, an open reader, the size in a listing) is
|
||||
// stale from here on, and the server may not have applied the swap yet.
|
||||
self.cache.evict(uid);
|
||||
self.evict_reader(uid);
|
||||
self.log_activity(
|
||||
ActivityKind::Restore,
|
||||
name,
|
||||
format!("restored version {revision_id}"),
|
||||
true,
|
||||
);
|
||||
info!(%uid, revision_id, name, "restored an earlier revision");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Permanently delete `revision_id` from the history of the file at `rel`.
|
||||
pub(crate) fn delete_revision(&self, rel: &Path, revision_id: &str) -> CoreResult<()> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
let name = rel
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| uid.to_string());
|
||||
self.delete_revision_for_uid(&uid, revision_id, &name)
|
||||
}
|
||||
|
||||
/// [`Core::delete_revision`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn delete_revision_by_uid(&self, uid: &str, revision_id: &str) -> CoreResult<()> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
let name = uid.to_string();
|
||||
self.delete_revision_for_uid(&uid, revision_id, &name)
|
||||
}
|
||||
|
||||
fn delete_revision_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
revision_id: &str,
|
||||
name: &str,
|
||||
) -> CoreResult<()> {
|
||||
self.require_uid_writable(uid)
|
||||
.map_err(|error| self.errno_error(error, "delete revision"))?;
|
||||
// The server rejects deleting the active revision; saying so here is a
|
||||
// sentence the user can act on instead of an API code, and it costs one
|
||||
// listing the front-end already made to show the version list.
|
||||
let revisions = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_revisions(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list revisions"))?;
|
||||
let target = revisions
|
||||
.iter()
|
||||
.find(|r| r.revision_id == revision_id)
|
||||
.ok_or_else(|| CoreError::not_found(format!("no version {revision_id} on {name}")))?;
|
||||
if target.is_active() {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"version {revision_id} is the current content of {name}; restore another version first"
|
||||
)));
|
||||
}
|
||||
self.rt
|
||||
.block_on(self.client.delete_revision(uid, revision_id))
|
||||
.map_err(|e| CoreError::from_api(&e, "delete revision"))?;
|
||||
self.log_activity(
|
||||
ActivityKind::DeleteForever,
|
||||
name,
|
||||
format!("deleted version {revision_id}"),
|
||||
true,
|
||||
);
|
||||
info!(%uid, revision_id, name, "deleted a revision");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `revision_id` of the file at `rel` to the absolute local path
|
||||
/// `dest`, leaving the file itself untouched.
|
||||
pub(crate) fn save_revision_as(
|
||||
&self,
|
||||
rel: &Path,
|
||||
revision_id: &str,
|
||||
dest: &Path,
|
||||
) -> CoreResult<u64> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
let name = rel
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| uid.to_string());
|
||||
self.save_revision_for_uid(&uid, revision_id, &name, dest)
|
||||
}
|
||||
|
||||
/// [`Core::save_revision_as`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn save_revision_as_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
revision_id: &str,
|
||||
dest: &Path,
|
||||
) -> CoreResult<u64> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
let name = uid.to_string();
|
||||
self.save_revision_for_uid(&uid, revision_id, &name, dest)
|
||||
}
|
||||
|
||||
fn save_revision_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
revision_id: &str,
|
||||
name: &str,
|
||||
dest: &Path,
|
||||
) -> CoreResult<u64> {
|
||||
if !dest.is_absolute() {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"destination must be an absolute path: {}",
|
||||
dest.display()
|
||||
)));
|
||||
}
|
||||
// Never silently replace something the user already has: an old version
|
||||
// written over the wrong file is exactly the loss this feature exists to
|
||||
// undo.
|
||||
if dest.exists() {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{} already exists",
|
||||
dest.display()
|
||||
)));
|
||||
}
|
||||
let file = std::fs::File::create(dest)
|
||||
.map_err(|e| CoreError::internal(format!("create {}: {e}", dest.display())))?;
|
||||
let mut out = std::io::BufWriter::new(file);
|
||||
let written = match self
|
||||
.rt
|
||||
.block_on(self.client.download_revision_to(uid, revision_id, &mut out))
|
||||
.map_err(|e| CoreError::from_api(&e, "download revision"))
|
||||
.and_then(|()| {
|
||||
use std::io::Write as _;
|
||||
out.flush()
|
||||
.map_err(|e| CoreError::internal(format!("write {}: {e}", dest.display())))?;
|
||||
let written = std::fs::metadata(dest)
|
||||
.map(|m| m.len())
|
||||
.map_err(|e| CoreError::internal(format!("stat {}: {e}", dest.display())))?;
|
||||
Ok(written)
|
||||
}) {
|
||||
Ok(written) => written,
|
||||
Err(error) => {
|
||||
// A half-written file looks like a successful export to every
|
||||
// tool that opens it afterwards.
|
||||
let _ = std::fs::remove_file(dest);
|
||||
self.log_activity(ActivityKind::Download, name, &error, false);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
self.log_activity(
|
||||
ActivityKind::Download,
|
||||
name,
|
||||
format!("saved version {revision_id} to {}", dest.display()),
|
||||
true,
|
||||
);
|
||||
info!(%uid, revision_id, dest = %dest.display(), "wrote an earlier revision to disk");
|
||||
Ok(written)
|
||||
}
|
||||
}
|
||||
+153
-22
@@ -19,10 +19,15 @@ use proton_drive_rs::Node;
|
||||
|
||||
use super::{Core, ROOT_INO, node_size, parse_uid, public_link_info, role_from_str, role_to_str};
|
||||
|
||||
/// A node from a *shared* listing as a [`DirEntry`]. Shared nodes live outside
|
||||
/// the mount, so the entry is uid-addressed: `path` is empty, and the local-state
|
||||
/// flags (`pinned`, `cached`) are false — they describe my own tree, not this one.
|
||||
fn shared_entry(n: Node) -> DirEntry {
|
||||
/// A node from a *shared* listing as a [`DirEntry`].
|
||||
///
|
||||
/// Still uid-addressed — that is the only handle a foreign-volume node is
|
||||
/// guaranteed to have — but `path` is filled in when the node is resident under
|
||||
/// the synthetic `Shared with me/` directory, which is what lets a front-end
|
||||
/// offer path-keyed actions on it. The local-state flags (`pinned`, `cached`)
|
||||
/// stay false: they describe content this account owns a local copy policy for,
|
||||
/// which a share on someone else's volume is not.
|
||||
fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
||||
let is_dir = n.is_folder();
|
||||
let size = match &n.kind {
|
||||
NodeKind::File {
|
||||
@@ -32,6 +37,13 @@ fn shared_entry(n: Node) -> DirEntry {
|
||||
} => claimed_size.unwrap_or(*total_size_on_storage).max(0) as u64,
|
||||
NodeKind::Folder => 0,
|
||||
};
|
||||
let role = n
|
||||
.membership
|
||||
.as_ref()
|
||||
.and_then(|m| m.role_exact())
|
||||
.map(role_to_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
DirEntry {
|
||||
name: n.name,
|
||||
is_dir,
|
||||
@@ -39,8 +51,9 @@ fn shared_entry(n: Node) -> DirEntry {
|
||||
modified: n.modification_time,
|
||||
pinned: false,
|
||||
cached: false,
|
||||
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
|
||||
uid: n.uid.to_string(),
|
||||
path: String::new(),
|
||||
role,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,11 +70,33 @@ impl Core {
|
||||
message: Option<&str>,
|
||||
) -> CoreResult<(usize, usize)> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.share_node_for_uid(&uid, emails, role, message)
|
||||
}
|
||||
|
||||
/// [`Core::share_node`] for a node addressed through any daemon location.
|
||||
pub(crate) fn share_node_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
emails: &[String],
|
||||
role: &str,
|
||||
message: Option<&str>,
|
||||
) -> CoreResult<(usize, usize)> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.share_node_for_uid(&uid, emails, role, message)
|
||||
}
|
||||
|
||||
fn share_node_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
emails: &[String],
|
||||
role: &str,
|
||||
message: Option<&str>,
|
||||
) -> CoreResult<(usize, usize)> {
|
||||
let role = role_from_str(role)?;
|
||||
let invitees: Vec<(String, MemberRole)> =
|
||||
emails.iter().map(|e| (e.clone(), role)).collect();
|
||||
self.rt
|
||||
.block_on(self.client.invite_users(&uid, &invitees, message))
|
||||
.block_on(self.client.invite_users(uid, &invitees, message))
|
||||
.map_err(|e| CoreError::from_api(&e, "share"))
|
||||
}
|
||||
|
||||
@@ -71,11 +106,26 @@ impl Core {
|
||||
rel: &Path,
|
||||
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.list_share_for_uid(&uid)
|
||||
}
|
||||
|
||||
/// [`Core::list_share`] for a node addressed through any daemon location.
|
||||
pub(crate) fn list_share_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.list_share_for_uid(&uid)
|
||||
}
|
||||
|
||||
fn list_share_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
||||
let mut entries = Vec::new();
|
||||
for m in self
|
||||
.rt
|
||||
.block_on(self.client.list_share_members(&uid))
|
||||
.block_on(self.client.list_share_members(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list members"))?
|
||||
{
|
||||
entries.push(ShareEntry {
|
||||
@@ -87,7 +137,7 @@ impl Core {
|
||||
}
|
||||
for inv in self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(&uid))
|
||||
.block_on(self.client.list_share_invitations(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
|
||||
{
|
||||
entries.push(ShareEntry {
|
||||
@@ -99,7 +149,7 @@ impl Core {
|
||||
}
|
||||
for ext in self
|
||||
.rt
|
||||
.block_on(self.client.list_external_invitations(&uid))
|
||||
.block_on(self.client.list_external_invitations(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?
|
||||
{
|
||||
entries.push(ShareEntry {
|
||||
@@ -112,7 +162,7 @@ impl Core {
|
||||
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(self.client.get_public_link(&uid))
|
||||
.block_on(self.client.get_public_link(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "get public link"))?
|
||||
.map(public_link_info);
|
||||
|
||||
@@ -129,12 +179,35 @@ impl Core {
|
||||
role: &str,
|
||||
) -> CoreResult<()> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.update_share_role_for_uid(&uid, id, kind, role)
|
||||
}
|
||||
|
||||
/// [`Core::update_share_role`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn update_share_role_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
id: &str,
|
||||
kind: ShareEntryKind,
|
||||
role: &str,
|
||||
) -> CoreResult<()> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.update_share_role_for_uid(&uid, id, kind, role)
|
||||
}
|
||||
|
||||
fn update_share_role_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
id: &str,
|
||||
kind: ShareEntryKind,
|
||||
role: &str,
|
||||
) -> CoreResult<()> {
|
||||
let role = role_from_str(role)?;
|
||||
match kind {
|
||||
ShareEntryKind::Member => {
|
||||
let member = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_members(&uid))
|
||||
.block_on(self.client.list_share_members(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list members"))?
|
||||
.into_iter()
|
||||
.find(|m| m.membership_id.to_string() == id)
|
||||
@@ -146,7 +219,7 @@ impl Core {
|
||||
ShareEntryKind::ProtonInvite => {
|
||||
let inv = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(&uid))
|
||||
.block_on(self.client.list_share_invitations(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
|
||||
.into_iter()
|
||||
.find(|i| i.invitation_id == id)
|
||||
@@ -170,11 +243,32 @@ impl Core {
|
||||
kind: ShareEntryKind,
|
||||
) -> CoreResult<()> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.remove_share_entry_for_uid(&uid, id, kind)
|
||||
}
|
||||
|
||||
/// [`Core::remove_share_entry`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn remove_share_entry_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
id: &str,
|
||||
kind: ShareEntryKind,
|
||||
) -> CoreResult<()> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.remove_share_entry_for_uid(&uid, id, kind)
|
||||
}
|
||||
|
||||
fn remove_share_entry_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
id: &str,
|
||||
kind: ShareEntryKind,
|
||||
) -> CoreResult<()> {
|
||||
match kind {
|
||||
ShareEntryKind::Member => {
|
||||
let member = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_members(&uid))
|
||||
.block_on(self.client.list_share_members(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list members"))?
|
||||
.into_iter()
|
||||
.find(|m| m.membership_id.to_string() == id)
|
||||
@@ -186,7 +280,7 @@ impl Core {
|
||||
ShareEntryKind::ProtonInvite => {
|
||||
let inv = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(&uid))
|
||||
.block_on(self.client.list_share_invitations(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
|
||||
.into_iter()
|
||||
.find(|i| i.invitation_id == id)
|
||||
@@ -198,7 +292,7 @@ impl Core {
|
||||
ShareEntryKind::ExternalInvite => {
|
||||
let ext = self
|
||||
.rt
|
||||
.block_on(self.client.list_external_invitations(&uid))
|
||||
.block_on(self.client.list_external_invitations(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?
|
||||
.into_iter()
|
||||
.find(|i| i.invitation_id == id)
|
||||
@@ -219,13 +313,33 @@ impl Core {
|
||||
expires: Option<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.create_public_link_for_uid(&uid, role, password, expires)
|
||||
}
|
||||
|
||||
/// [`Core::create_public_link`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn create_public_link_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
role: &str,
|
||||
password: Option<&str>,
|
||||
expires: Option<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.create_public_link_for_uid(&uid, role, password, expires)
|
||||
}
|
||||
|
||||
fn create_public_link_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
role: &str,
|
||||
password: Option<&str>,
|
||||
expires: Option<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
let role = role_from_str(role)?;
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(
|
||||
self.client
|
||||
.create_public_link(&uid, role, password, expires),
|
||||
)
|
||||
.block_on(self.client.create_public_link(uid, role, password, expires))
|
||||
.map_err(|e| CoreError::from_api(&e, "create public link"))?;
|
||||
Ok(public_link_info(link))
|
||||
}
|
||||
@@ -233,9 +347,20 @@ impl Core {
|
||||
/// Remove the public link `id` from the node at `rel`.
|
||||
pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> CoreResult<()> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.remove_public_link_for_uid(&uid, id)
|
||||
}
|
||||
|
||||
/// [`Core::remove_public_link`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn remove_public_link_by_uid(&self, uid: &str, id: &str) -> CoreResult<()> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.remove_public_link_for_uid(&uid, id)
|
||||
}
|
||||
|
||||
fn remove_public_link_for_uid(&self, uid: &NodeUid, id: &str) -> CoreResult<()> {
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(self.client.get_public_link(&uid))
|
||||
.block_on(self.client.get_public_link(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "get public link"))?
|
||||
.filter(|l| l.public_link_id == id)
|
||||
.ok_or_else(|| CoreError::not_found("public link not found"))?;
|
||||
@@ -259,7 +384,10 @@ impl Core {
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
||||
Ok(nodes.into_iter().map(shared_entry).collect())
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
.map(|node| shared_entry_of(self, node))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// List the children of a folder shared with me, addressed by uid.
|
||||
@@ -281,7 +409,10 @@ impl Core {
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(&child_uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
||||
Ok(nodes.into_iter().map(shared_entry).collect())
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
.map(|node| shared_entry_of(self, node))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Download a file shared with me into the content cache, returning its
|
||||
|
||||
+1055
-3
File diff suppressed because it is too large.
Load diff
@@ -21,6 +21,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use pdfs_core::batch;
|
||||
use pdfs_core::config::SweepMode;
|
||||
use pdfs_core::control::ActivityKind;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
@@ -82,7 +83,11 @@ impl Core {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let nodes: Vec<_> = stored.into_iter().map(|s| s.node).collect();
|
||||
let nodes: Vec<_> = stored
|
||||
.into_iter()
|
||||
.map(|stored| stored.node)
|
||||
.filter(|node| self.is_own_or_virtual(&node.uid))
|
||||
.collect();
|
||||
|
||||
// Index live (non-trashed) files by (parent, name) for sibling lookup.
|
||||
let mut by_parent_name: HashMap<(String, &str), &_> = HashMap::new();
|
||||
@@ -145,6 +150,7 @@ impl Core {
|
||||
match self
|
||||
.rt
|
||||
.block_on(self.client.trash_nodes(std::slice::from_ref(uid)))
|
||||
.and_then(batch::into_unit)
|
||||
{
|
||||
Ok(()) => {}
|
||||
Err(e) if super::is_gone(&e) => {}
|
||||
@@ -160,7 +166,10 @@ impl Core {
|
||||
self.for_each_state(|st| {
|
||||
st.forget_or_unlink(uid);
|
||||
});
|
||||
self.discard_queued_ops(uid);
|
||||
if let Err(error) = self.discard_queued_ops(uid) {
|
||||
warn!(%uid, ?error, "conflict sweep: queued-op cleanup failed");
|
||||
return;
|
||||
}
|
||||
self.cache.evict(uid);
|
||||
self.evict_reader(uid);
|
||||
if let Err(e) = self.db.delete_node(uid) {
|
||||
@@ -346,6 +355,9 @@ mod tests {
|
||||
is_shared: false,
|
||||
is_shared_publicly: false,
|
||||
signature_email: None,
|
||||
membership: None,
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ use tracing::{debug, info, warn};
|
||||
|
||||
use crate::transfers::{CountingWriter, OwnedCountingReader};
|
||||
use crate::{Core, now_secs, parse_uid};
|
||||
use pdfs_core::batch;
|
||||
use pdfs_core::control::{ActivityKind, TransferDirection};
|
||||
use pdfs_core::db::{StoredSyncEntry, StoredSyncFolder};
|
||||
use pdfs_core::syncignore::IgnoreRules;
|
||||
@@ -513,7 +514,11 @@ impl Core {
|
||||
let _ = self.db.sync_entry_remove(folder_id, rel);
|
||||
continue;
|
||||
};
|
||||
if let Err(e) = self.rt.block_on(self.client.trash_nodes(&[uid])) {
|
||||
if let Err(e) = self
|
||||
.rt
|
||||
.block_on(self.client.trash_nodes(&[uid]))
|
||||
.and_then(batch::into_unit)
|
||||
{
|
||||
warn!(rel, error = %e, "sync: trash remote failed");
|
||||
self.log_activity(ActivityKind::Trash, base_name(rel), e.to_string(), false);
|
||||
outcome.errors += 1;
|
||||
@@ -754,6 +759,7 @@ impl Core {
|
||||
if let Err(e) = self
|
||||
.rt
|
||||
.block_on(self.client.trash_nodes(std::slice::from_ref(&remote.uid)))
|
||||
.and_then(batch::into_unit)
|
||||
{
|
||||
warn!(rel, error = %e, "sync: trash remote failed");
|
||||
self.log_activity(
|
||||
@@ -808,7 +814,11 @@ impl Core {
|
||||
}
|
||||
delete_remote_dirs.sort_by_key(|(p, _)| std::cmp::Reverse(p.matches('/').count()));
|
||||
for (rel, uid) in delete_remote_dirs {
|
||||
if let Err(e) = self.rt.block_on(self.client.trash_nodes(&[uid])) {
|
||||
if let Err(e) = self
|
||||
.rt
|
||||
.block_on(self.client.trash_nodes(&[uid]))
|
||||
.and_then(batch::into_unit)
|
||||
{
|
||||
warn!(rel, error = %e, "sync: trash remote folder failed");
|
||||
self.log_activity(ActivityKind::Trash, base_name(&rel), e.to_string(), false);
|
||||
outcome.errors += 1;
|
||||
|
||||
@@ -63,6 +63,12 @@ async fn run_uploads(
|
||||
.transfers
|
||||
.begin(&t.name, "", TransferDirection::Upload, t.size);
|
||||
let reader = OwnedCountingReader::new(file, guard);
|
||||
if let Err(error) = core.require_uid_writable(&t.parent_uid) {
|
||||
return Err((
|
||||
t.name,
|
||||
format!("destination access denied: errno {}", error.code()),
|
||||
));
|
||||
}
|
||||
match core
|
||||
.client
|
||||
.upload_file_from(
|
||||
@@ -110,6 +116,8 @@ impl Core {
|
||||
let (pino, parent_uid) = self.resolve(parent_rel)?;
|
||||
self.ensure_children(pino)
|
||||
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
||||
self.require_uid_writable(&parent_uid)
|
||||
.map_err(|error| self.errno_error(error, "upload destination"))?;
|
||||
|
||||
// Phase 1 (sequential): build the remote folder skeleton and collect the
|
||||
// flat list of files to upload. Folders must exist before their children,
|
||||
@@ -170,6 +178,8 @@ impl Core {
|
||||
{
|
||||
kids.push(ino);
|
||||
}
|
||||
drop(st);
|
||||
self.flush_access_changes();
|
||||
}
|
||||
// Uploaded fine but the metadata refresh failed; it will
|
||||
// appear on the next directory enumeration regardless.
|
||||
@@ -205,19 +215,24 @@ impl Core {
|
||||
}
|
||||
self.ensure_children(pino)
|
||||
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
||||
{
|
||||
let existing = {
|
||||
let st = self.state.lock();
|
||||
if let Some(kids) = st.children.get(&pino) {
|
||||
for &ino in kids {
|
||||
if let Some(e) = st.entries.get(&ino)
|
||||
&& e.node.is_folder()
|
||||
&& e.node.name == name
|
||||
{
|
||||
return Ok((ino, e.uid.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
st.children.get(&pino).and_then(|kids| {
|
||||
kids.iter().find_map(|ino| {
|
||||
st.entries
|
||||
.get(ino)
|
||||
.filter(|entry| entry.node.is_folder() && entry.node.name == name)
|
||||
.map(|entry| (*ino, entry.uid.clone()))
|
||||
})
|
||||
})
|
||||
};
|
||||
if let Some((ino, uid)) = existing {
|
||||
self.require_uid_writable(&uid)
|
||||
.map_err(|error| self.errno_error(error, "upload destination"))?;
|
||||
return Ok((ino, uid));
|
||||
}
|
||||
self.require_uid_writable(parent_uid)
|
||||
.map_err(|error| self.errno_error(error, "upload destination"))?;
|
||||
let new_uid = self
|
||||
.rt
|
||||
.block_on(
|
||||
@@ -235,6 +250,10 @@ impl Core {
|
||||
{
|
||||
kids.push(ino);
|
||||
}
|
||||
drop(st);
|
||||
self.flush_access_changes();
|
||||
self.require_uid_writable(&new_uid)
|
||||
.map_err(|error| self.errno_error(error, "created upload destination"))?;
|
||||
Ok((ino, new_uid))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
//! Synthetic nodes and naming rules for the mounted shared tree.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||
use proton_drive_rs::{Node, NodeKind};
|
||||
|
||||
pub(crate) const VIRTUAL_VOLUME: &str = "virtual";
|
||||
pub(crate) const SHARED_WITH_ME_LINK: &str = "sharedwithme";
|
||||
pub(crate) const SHARED_WITH_ME_BASE: &str = "Shared with me";
|
||||
const MAX_COMPONENT_BYTES: usize = 255;
|
||||
|
||||
pub(crate) fn shared_with_me_uid() -> NodeUid {
|
||||
NodeUid::new(
|
||||
VolumeId::from(VIRTUAL_VOLUME),
|
||||
LinkId::from(SHARED_WITH_ME_LINK),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn is_virtual_uid(uid: &NodeUid) -> bool {
|
||||
uid.volume_id.as_str() == VIRTUAL_VOLUME
|
||||
}
|
||||
|
||||
pub(crate) fn is_own_or_virtual_uid(uid: &NodeUid, own_volume: &VolumeId) -> bool {
|
||||
uid.volume_id == *own_volume || is_virtual_uid(uid)
|
||||
}
|
||||
|
||||
pub(crate) fn is_primary_root_listing(
|
||||
primary: bool,
|
||||
folder_uid: &NodeUid,
|
||||
primary_root_uid: &NodeUid,
|
||||
) -> bool {
|
||||
primary && folder_uid == primary_root_uid
|
||||
}
|
||||
|
||||
pub(crate) fn virtual_node(parent_uid: NodeUid, name: String, timestamp: i64) -> Node {
|
||||
Node {
|
||||
uid: shared_with_me_uid(),
|
||||
parent_uid: Some(parent_uid),
|
||||
name,
|
||||
kind: NodeKind::Folder,
|
||||
creation_time: timestamp,
|
||||
modification_time: timestamp,
|
||||
trashed: false,
|
||||
is_shared: false,
|
||||
is_shared_publicly: false,
|
||||
signature_email: None,
|
||||
membership: None,
|
||||
// A synthetic folder is neither a photo nor an album.
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Choose the initial synthetic-root name. Once persisted, `pinned` always wins;
|
||||
/// a later real collision only controls whether the synthetic dentry is exposed.
|
||||
pub(crate) fn virtual_root_name(
|
||||
real_names: &HashSet<String>,
|
||||
pinned: Option<&str>,
|
||||
) -> (String, bool) {
|
||||
if let Some(pinned) = pinned {
|
||||
return (pinned.to_string(), !real_names.contains(pinned));
|
||||
}
|
||||
for n in 0usize.. {
|
||||
let candidate = match n {
|
||||
0 => SHARED_WITH_ME_BASE.to_string(),
|
||||
1 => format!("{SHARED_WITH_ME_BASE} (Proton)"),
|
||||
_ => format!("{SHARED_WITH_ME_BASE} (Proton {n})"),
|
||||
};
|
||||
if !real_names.contains(&candidate) {
|
||||
return (candidate, true);
|
||||
}
|
||||
}
|
||||
unreachable!("the numbered virtual-root namespace is unbounded")
|
||||
}
|
||||
|
||||
/// Give every shared root a unique Linux component name, independent of API
|
||||
/// ordering. Literal suffix-shaped names reserve their basename before duplicate
|
||||
/// names are numbered, so `Photos (2)` is never stolen by a second `Photos`.
|
||||
pub(crate) fn disambiguate_shared_names(nodes: &mut [Node]) {
|
||||
nodes.sort_by(|a, b| {
|
||||
a.name
|
||||
.cmp(&b.name)
|
||||
.then_with(|| a.uid.to_string().cmp(&b.uid.to_string()))
|
||||
});
|
||||
let reserved: HashSet<String> = nodes
|
||||
.iter()
|
||||
.map(|node| component_with_suffix(&node.name, ""))
|
||||
.collect();
|
||||
let mut used = HashSet::with_capacity(nodes.len());
|
||||
|
||||
for node in nodes {
|
||||
let original = node.name.clone();
|
||||
let basename = component_with_suffix(&original, "");
|
||||
let chosen = if used.insert(basename.clone()) {
|
||||
basename
|
||||
} else {
|
||||
let mut n = 2usize;
|
||||
loop {
|
||||
let suffix = format!(" ({n})");
|
||||
let candidate = component_with_suffix(&original, &suffix);
|
||||
if !reserved.contains(&candidate) && used.insert(candidate.clone()) {
|
||||
break candidate;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
};
|
||||
node.name = chosen;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn listing_needs_refresh(online: bool, stale: bool) -> bool {
|
||||
online && stale
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum SharedListingPlan {
|
||||
Resident,
|
||||
Persisted,
|
||||
Refresh,
|
||||
}
|
||||
|
||||
pub(crate) fn shared_listing_plan(resident: bool, online: bool, stale: bool) -> SharedListingPlan {
|
||||
if listing_needs_refresh(online, stale) {
|
||||
SharedListingPlan::Refresh
|
||||
} else if resident {
|
||||
SharedListingPlan::Resident
|
||||
} else {
|
||||
SharedListingPlan::Persisted
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_generation_is_current(started: u64, current: u64) -> bool {
|
||||
started == current
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct SharedRefreshDeadlines {
|
||||
deadlines: HashMap<String, Instant>,
|
||||
}
|
||||
|
||||
impl SharedRefreshDeadlines {
|
||||
pub(crate) fn is_fresh(&mut self, key: &str, now: Instant) -> bool {
|
||||
self.deadlines.retain(|_, deadline| *deadline > now);
|
||||
self.deadlines
|
||||
.get(key)
|
||||
.is_some_and(|deadline| *deadline > now)
|
||||
}
|
||||
|
||||
pub(crate) fn mark(&mut self, key: &str, now: Instant, ttl: Duration) {
|
||||
self.deadlines.insert(key.to_string(), now + ttl);
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.deadlines.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn component_with_suffix(name: &str, suffix: &str) -> String {
|
||||
let keep = MAX_COMPONENT_BYTES.saturating_sub(suffix.len());
|
||||
let mut boundary = name.len().min(keep);
|
||||
while !name.is_char_boundary(boundary) {
|
||||
boundary -= 1;
|
||||
}
|
||||
let mut out = String::with_capacity(boundary + suffix.len());
|
||||
out.push_str(&name[..boundary]);
|
||||
out.push_str(suffix);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn folder(volume: &str, link: &str, name: &str) -> Node {
|
||||
let mut node = virtual_node(
|
||||
NodeUid::new(VolumeId::from("virtual"), LinkId::from("parent")),
|
||||
name.to_string(),
|
||||
1,
|
||||
);
|
||||
node.uid = NodeUid::new(VolumeId::from(volume), LinkId::from(link));
|
||||
node
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_uid_and_node_round_trip() {
|
||||
let parent = NodeUid::new(VolumeId::from("own"), LinkId::from("root"));
|
||||
let node = virtual_node(parent.clone(), SHARED_WITH_ME_BASE.into(), 42);
|
||||
assert_eq!(node.uid.to_string(), "virtual~sharedwithme");
|
||||
assert!(is_virtual_uid(&node.uid));
|
||||
assert_eq!(node.parent_uid.as_ref(), Some(&parent));
|
||||
assert!(node.is_folder());
|
||||
let json = serde_json::to_string(&node).unwrap();
|
||||
let restored: Node = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(restored.uid, node.uid);
|
||||
assert_eq!(restored.name, SHARED_WITH_ME_BASE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_collision_ladder_and_pinned_collision_policy() {
|
||||
let names = HashSet::from([
|
||||
SHARED_WITH_ME_BASE.to_string(),
|
||||
format!("{SHARED_WITH_ME_BASE} (Proton)"),
|
||||
format!("{SHARED_WITH_ME_BASE} (Proton 2)"),
|
||||
]);
|
||||
assert_eq!(
|
||||
virtual_root_name(&names, None),
|
||||
(format!("{SHARED_WITH_ME_BASE} (Proton 3)"), true)
|
||||
);
|
||||
|
||||
let (name, visible) = virtual_root_name(&names, Some(SHARED_WITH_ME_BASE));
|
||||
assert_eq!(name, SHARED_WITH_ME_BASE);
|
||||
assert!(!visible, "a real later collision suppresses the dentry");
|
||||
|
||||
let cleared = HashSet::new();
|
||||
assert_eq!(
|
||||
virtual_root_name(&cleared, Some(SHARED_WITH_ME_BASE)),
|
||||
(SHARED_WITH_ME_BASE.to_string(), true),
|
||||
"the pinned name returns when the collision clears"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_names_are_stable_under_reordering_and_reserve_suffixes() {
|
||||
let input = vec![
|
||||
folder("b", "2", "Photos"),
|
||||
folder("c", "3", "Photos (2)"),
|
||||
folder("a", "1", "Photos"),
|
||||
];
|
||||
let mut forward = input.clone();
|
||||
let mut reverse = input;
|
||||
reverse.reverse();
|
||||
disambiguate_shared_names(&mut forward);
|
||||
disambiguate_shared_names(&mut reverse);
|
||||
let summarize = |nodes: &[Node]| {
|
||||
nodes
|
||||
.iter()
|
||||
.map(|node| (node.uid.to_string(), node.name.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(summarize(&forward), summarize(&reverse));
|
||||
assert_eq!(
|
||||
forward
|
||||
.iter()
|
||||
.map(|node| node.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["Photos", "Photos (3)", "Photos (2)"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disambiguation_respects_utf8_component_limit() {
|
||||
let long = "é".repeat(200);
|
||||
let mut nodes = vec![folder("a", "1", &long), folder("b", "2", &long)];
|
||||
disambiguate_shared_names(&mut nodes);
|
||||
assert!(nodes.iter().all(|node| node.name.len() <= 255));
|
||||
assert!(
|
||||
nodes
|
||||
.iter()
|
||||
.all(|node| node.name.is_char_boundary(node.name.len()))
|
||||
);
|
||||
assert_ne!(nodes[0].name, nodes[1].name);
|
||||
assert!(nodes[1].name.ends_with(" (2)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ttl_refresh_only_happens_online() {
|
||||
assert!(!listing_needs_refresh(false, true));
|
||||
assert!(!listing_needs_refresh(true, false));
|
||||
assert!(listing_needs_refresh(true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_snapshot_suppresses_the_api_and_memory_deadline_suppresses_retries() {
|
||||
assert_eq!(
|
||||
shared_listing_plan(false, false, true),
|
||||
SharedListingPlan::Persisted
|
||||
);
|
||||
assert_eq!(
|
||||
shared_listing_plan(true, true, false),
|
||||
SharedListingPlan::Resident
|
||||
);
|
||||
|
||||
let now = Instant::now();
|
||||
let mut deadlines = SharedRefreshDeadlines::default();
|
||||
deadlines.mark("shared", now, Duration::from_secs(60));
|
||||
assert!(deadlines.is_fresh("shared", now + Duration::from_secs(30)));
|
||||
deadlines.clear();
|
||||
assert!(!deadlines.is_fresh("shared", now + Duration::from_secs(30)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_change_rejects_an_inflight_response() {
|
||||
assert!(refresh_generation_is_current(7, 7));
|
||||
assert!(!refresh_generation_is_current(7, 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_primary_inode_space_installs_the_synthetic_root() {
|
||||
let root = NodeUid::new(VolumeId::from("own"), LinkId::from("root"));
|
||||
assert!(is_primary_root_listing(true, &root, &root));
|
||||
assert!(!is_primary_root_listing(false, &root, &root));
|
||||
assert!(!is_primary_root_listing(
|
||||
true,
|
||||
&NodeUid::new(VolumeId::from("own"), LinkId::from("device")),
|
||||
&root
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ownership_predicate_excludes_foreign_share_volumes() {
|
||||
let own = VolumeId::from("own");
|
||||
let own_uid = NodeUid::new(own.clone(), LinkId::from("file"));
|
||||
let foreign = NodeUid::new(VolumeId::from("foreign"), LinkId::from("share"));
|
||||
assert!(is_own_or_virtual_uid(&own_uid, &own));
|
||||
assert!(is_own_or_virtual_uid(&shared_with_me_uid(), &own));
|
||||
assert!(!is_own_or_virtual_uid(&foreign, &own));
|
||||
}
|
||||
}
|
||||
+111
-157
@@ -4,8 +4,10 @@ pub(crate) mod pages;
|
||||
pub(crate) mod widgets;
|
||||
|
||||
use pages::activity::*;
|
||||
use pages::albums::*;
|
||||
use pages::browser::*;
|
||||
use pages::devices::*;
|
||||
use pages::locations::*;
|
||||
use pages::login::*;
|
||||
use pages::photo_viewer::*;
|
||||
use pages::photos::*;
|
||||
@@ -16,6 +18,7 @@ use pages::trash::*;
|
||||
use pages::verify::*;
|
||||
use widgets::details::*;
|
||||
use widgets::share_dialog::*;
|
||||
use widgets::versions_dialog::*;
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
|
||||
@@ -42,13 +45,15 @@ use pdfs_core::auth;
|
||||
use pdfs_core::config::AppDirs;
|
||||
|
||||
use pdfs_core::control::{
|
||||
ActivityEntry, ActivityKind, BookmarkInfo, DeviceInfo, DirEntry, ErrorKind, InvitationInfo,
|
||||
JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response,
|
||||
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, DeviceInfo, DirEntry, ErrorKind,
|
||||
InvitationInfo, JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response,
|
||||
RestorableFolder, RestoreItem, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
|
||||
SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection, TransferItem, pending_summary,
|
||||
send,
|
||||
};
|
||||
|
||||
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
||||
|
||||
use pdfs_core::service;
|
||||
|
||||
const APP_ID: &str = "io.narl.proton-drive-linux";
|
||||
@@ -113,6 +118,7 @@ struct Ui {
|
||||
pub(crate) shared: SharedState,
|
||||
pub(crate) shared_by_me: SharedByMeState,
|
||||
pub(crate) devices: DevicesState,
|
||||
pub(crate) locations: LocationsState,
|
||||
pub(crate) activity: ActivityState,
|
||||
}
|
||||
|
||||
@@ -134,23 +140,10 @@ impl Ui {
|
||||
}
|
||||
}
|
||||
|
||||
/// The aspect ratio (w/h) to lay `uid`'s tile out at: the real one once its
|
||||
/// thumbnail has been seen, otherwise [`RATIO_UNKNOWN`].
|
||||
fn ratio(&self, uid: &str) -> f64 {
|
||||
self.gallery
|
||||
.photo_ratio
|
||||
.borrow()
|
||||
.get(uid)
|
||||
.copied()
|
||||
.unwrap_or(RATIO_UNKNOWN)
|
||||
}
|
||||
|
||||
/// Remember a decoded thumbnail and the aspect ratio it revealed. Returns
|
||||
/// true when the ratio is *new information* — the caller re-justifies the
|
||||
/// affected rows, since they were laid out against a guess.
|
||||
fn store_texture(&self, uid: &str, texture: gtk4::gdk::Texture) -> bool {
|
||||
let (w, h) = (texture.width(), texture.height());
|
||||
|
||||
/// Remember a decoded thumbnail, evicting the oldest once the cache is full.
|
||||
/// Scrolling back over a day then repaints from memory rather than decoding
|
||||
/// the same JPEGs off disk again.
|
||||
fn store_texture(&self, uid: &str, texture: gtk4::gdk::Texture) {
|
||||
let mut cache = self.gallery.photo_tex.borrow_mut();
|
||||
let mut order = self.gallery.photo_tex_order.borrow_mut();
|
||||
if cache.insert(uid.to_string(), texture).is_none() {
|
||||
@@ -161,58 +154,6 @@ impl Ui {
|
||||
cache.remove(&old);
|
||||
}
|
||||
}
|
||||
drop((cache, order));
|
||||
|
||||
if h <= 0 {
|
||||
return false;
|
||||
}
|
||||
let ratio = f64::from(w) / f64::from(h);
|
||||
let mut ratios = self.gallery.photo_ratio.borrow_mut();
|
||||
match ratios.insert(uid.to_string(), ratio) {
|
||||
// Only a *changed* ratio invalidates a layout; re-decoding a photo we
|
||||
// already sized correctly must not trigger another pass.
|
||||
Some(prev) if (prev - ratio).abs() < f64::EPSILON => false,
|
||||
_ => {
|
||||
self.gallery.photo_ratio_dirty.set(true);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the learned aspect ratios are persisted between runs.
|
||||
fn ratio_path(&self) -> PathBuf {
|
||||
self.dirs.cache_dir().join("photo-ratios.json")
|
||||
}
|
||||
|
||||
/// Load the persisted aspect ratios. A missing or corrupt file just means the
|
||||
/// first paint uses [`RATIO_UNKNOWN`] and re-justifies as thumbnails land.
|
||||
fn load_ratios(&self) {
|
||||
let path = self.ratio_path();
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
return;
|
||||
};
|
||||
match serde_json::from_str::<HashMap<String, f64>>(&text) {
|
||||
Ok(map) => *self.gallery.photo_ratio.borrow_mut() = map,
|
||||
Err(e) => tracing::debug!("ignoring {}: {e}", path.display()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the learned aspect ratios, if any were learned since the last save.
|
||||
fn save_ratios(&self) {
|
||||
if !self.gallery.photo_ratio_dirty.replace(false) {
|
||||
return;
|
||||
}
|
||||
let path = self.ratio_path();
|
||||
let ratios = self.gallery.photo_ratio.borrow();
|
||||
let write = serde_json::to_vec(&*ratios)
|
||||
.map_err(std::io::Error::other)
|
||||
.and_then(|bytes| {
|
||||
std::fs::create_dir_all(path.parent().unwrap_or(Path::new("/")))?;
|
||||
std::fs::write(&path, bytes)
|
||||
});
|
||||
if let Err(e) = write {
|
||||
tracing::debug!("cannot save {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,15 +224,23 @@ fn load_proton_theme() {
|
||||
.viewer-spinner {{ color: white; }}\n\
|
||||
.viewer-status {{ color: white; font-size: 1rem; background-color: rgba(0, 0, 0, 0.75); padding: 12px 24px; border-radius: 12px; }}\n\
|
||||
.viewer-info-panel {{ background-color: @window_bg_color; border-left: 1px solid alpha(currentColor, 0.12); }}\n\
|
||||
.gallery-day {{ font-size: 1.05rem; font-weight: 700; padding: 2px 2px 6px 2px; }}\n\
|
||||
.photo-tile {{ padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 10px; background: alpha(currentColor, 0.06); box-shadow: none; transition: filter 160ms ease, box-shadow 160ms ease; }}\n\
|
||||
.photo-tile:hover {{ filter: brightness(1.06); box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); }}\n\
|
||||
.gallery-day {{ font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: alpha(currentColor, 0.65); padding: 4px 2px 8px 2px; }}\n\
|
||||
.photo-tile {{ padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 14px; background: alpha(currentColor, 0.06); box-shadow: none; transition: box-shadow 180ms ease, background 180ms ease; }}\n\
|
||||
.photo-tile:hover {{ box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); }}\n\
|
||||
.photo-tile:focus {{ outline: 2px solid {PROTON_PURPLE}; outline-offset: -2px; }}\n\
|
||||
.photo-thumb {{ transition: transform 220ms ease; }}\n\
|
||||
.photo-tile:hover .photo-thumb {{ transform: scale(1.06); }}\n\
|
||||
.photo-placeholder {{ color: alpha(currentColor, 0.35); background: alpha(currentColor, 0.07); }}\n\
|
||||
.photo-caption {{ font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 160ms ease; }}\n\
|
||||
.photo-caption {{ font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 180ms ease; }}\n\
|
||||
.photo-video-badge {{ color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }}\n\
|
||||
.photo-tile:hover .photo-video-badge {{ background: alpha({PROTON_PURPLE}, 0.85); }}\n\
|
||||
.photo-tile:hover .photo-caption {{ opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }}\n\
|
||||
.album-card {{ padding: 0; border-radius: 16px; transition: background 180ms ease; }}\n\
|
||||
.album-card:hover {{ background: alpha(currentColor, 0.07); }}\n\
|
||||
.album-cover {{ border-radius: 16px; background: alpha(currentColor, 0.06); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); transition: box-shadow 180ms ease; }}\n\
|
||||
.album-card:hover .album-cover {{ box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); }}\n\
|
||||
.album-card:hover .photo-thumb {{ transform: scale(1.05); }}\n\
|
||||
.view-switch button {{ padding-left: 18px; padding-right: 18px; }}\n\
|
||||
.card {{ border-radius: 8px; transition: transform 0.2s ease, filter 0.2s ease; margin: 4px; }}\n\
|
||||
.card:hover {{ transform: scale(1.02); filter: brightness(0.9); }}\n\
|
||||
.navigation-sidebar row {{ border-radius: 8px; margin: 2px 6px; }}\n\
|
||||
@@ -333,6 +282,7 @@ fn build_window(app: &adw::Application) {
|
||||
let (shared_page, shared_widgets) = build_shared_page();
|
||||
let (shared_by_me_page, shared_by_me_widgets) = build_shared_by_me_page();
|
||||
let (devices_page, devices_widgets) = build_devices_page();
|
||||
let (locations_page, locations_widgets) = build_locations_page();
|
||||
let (activity_page, activity_widgets) = build_activity_page();
|
||||
let (trash_page, trash_widgets) = build_trash_page();
|
||||
stack.add_named(&login_page, Some("login"));
|
||||
@@ -342,6 +292,7 @@ fn build_window(app: &adw::Application) {
|
||||
stack.add_named(&shared_by_me_page, Some("sharedbyme"));
|
||||
stack.add_named(&shared_page, Some("shared"));
|
||||
stack.add_named(&devices_page, Some("devices"));
|
||||
stack.add_named(&locations_page, Some("locations"));
|
||||
stack.add_named(&activity_page, Some("activity"));
|
||||
stack.add_named(&trash_page, Some("trash"));
|
||||
|
||||
@@ -456,19 +407,31 @@ fn build_window(app: &adw::Application) {
|
||||
retry: gallery_widgets.retry.clone(),
|
||||
more: gallery_widgets.more.clone(),
|
||||
upload: gallery_widgets.upload.clone(),
|
||||
title: gallery_widgets.title.clone(),
|
||||
subtitle: gallery_widgets.subtitle.clone(),
|
||||
albums: gallery_widgets.albums.clone(),
|
||||
albums_stack: gallery_widgets.albums_stack.clone(),
|
||||
albums_status: gallery_widgets.albums_status.clone(),
|
||||
photos_btn: gallery_widgets.photos_btn.clone(),
|
||||
albums_btn: gallery_widgets.albums_btn.clone(),
|
||||
view_switch: gallery_widgets.view_switch.clone(),
|
||||
albums_loading: Cell::new(false),
|
||||
album: RefCell::new(None),
|
||||
back: gallery_widgets.back.clone(),
|
||||
filters: gallery_widgets.filters.clone(),
|
||||
kind: Cell::new(None),
|
||||
tabs: gallery_widgets.tabs.clone(),
|
||||
counts: Cell::new(None),
|
||||
dates: gallery_widgets.dates.clone(),
|
||||
date_ranges: RefCell::new(vec![None]),
|
||||
range: Cell::new(None),
|
||||
favorites_btn: gallery_widgets.favorites_btn.clone(),
|
||||
favorites: Cell::new(false),
|
||||
date_suppress: Cell::new(false),
|
||||
loading: Cell::new(false),
|
||||
width: Cell::new(0),
|
||||
photo_tex: RefCell::new(HashMap::new()),
|
||||
photo_tex_order: RefCell::new(VecDeque::new()),
|
||||
photo_ratio: RefCell::new(HashMap::new()),
|
||||
photo_ratio_dirty: Cell::new(false),
|
||||
photo_nothumb: RefCell::new(HashSet::new()),
|
||||
thumb_wanted: RefCell::new(HashMap::new()),
|
||||
thumb_queue: RefCell::new(VecDeque::new()),
|
||||
@@ -513,6 +476,15 @@ fn build_window(app: &adw::Application) {
|
||||
inflight: Cell::new(false),
|
||||
loaded_at: Cell::new(None),
|
||||
},
|
||||
locations: LocationsState {
|
||||
content: locations_widgets.content.clone(),
|
||||
status: locations_widgets.status.clone(),
|
||||
retry: locations_widgets.retry.clone(),
|
||||
group: locations_widgets.group.clone(),
|
||||
rows: RefCell::new(Vec::new()),
|
||||
inflight: Cell::new(false),
|
||||
loaded_at: Cell::new(None),
|
||||
},
|
||||
activity: ActivityState {
|
||||
content: activity_widgets.content.clone(),
|
||||
status: activity_widgets.status.clone(),
|
||||
@@ -523,8 +495,6 @@ fn build_window(app: &adw::Application) {
|
||||
key: RefCell::new(None),
|
||||
},
|
||||
});
|
||||
ui.load_ratios();
|
||||
|
||||
wire_login(&ui);
|
||||
wire_logout(&ui, &main_widgets.logout_button);
|
||||
wire_settings(
|
||||
@@ -543,15 +513,12 @@ fn build_window(app: &adw::Application) {
|
||||
wire_details(&ui);
|
||||
wire_search(&ui);
|
||||
wire_gallery(&ui, &gallery_widgets.list, &gallery_widgets.scroll);
|
||||
wire_albums(&ui);
|
||||
wire_trash(&ui, &trash_widgets.list, &trash_widgets.empty);
|
||||
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
|
||||
wire_shared_by_me(&ui, &shared_by_me_widgets.retry);
|
||||
wire_devices(
|
||||
&ui,
|
||||
&devices_widgets.retry,
|
||||
&devices_widgets.add_folder,
|
||||
&devices_widgets.restore,
|
||||
);
|
||||
wire_devices(&ui, &devices_widgets.retry, &devices_widgets.restore);
|
||||
wire_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder);
|
||||
wire_activity(&ui, &activity_widgets.retry);
|
||||
wire_refresh(
|
||||
&ui,
|
||||
@@ -562,6 +529,7 @@ fn build_window(app: &adw::Application) {
|
||||
&shared_widgets.refresh,
|
||||
&shared_by_me_widgets.refresh,
|
||||
&devices_widgets.refresh,
|
||||
&locations_widgets.refresh,
|
||||
&activity_widgets.refresh,
|
||||
],
|
||||
);
|
||||
@@ -584,6 +552,8 @@ fn build_window(app: &adw::Application) {
|
||||
Some("shared") => load_shared(&ui_nav),
|
||||
Some("devices") if page_fresh(&ui_nav.devices.loaded_at) => {}
|
||||
Some("devices") => load_devices(&ui_nav),
|
||||
Some("locations") if page_fresh(&ui_nav.locations.loaded_at) => {}
|
||||
Some("locations") => load_locations(&ui_nav),
|
||||
// Activity is intentionally not TTL-cached: it changes out from under
|
||||
// the page as background uploads and edits complete, so it reloads on
|
||||
// every visit to stay live.
|
||||
@@ -612,12 +582,10 @@ fn build_window(app: &adw::Application) {
|
||||
glib::ControlFlow::Continue
|
||||
});
|
||||
let cell = RefCell::new(Some(source));
|
||||
let ui_close = ui.clone();
|
||||
window.connect_close_request(move |_| {
|
||||
if let Some(id) = cell.borrow_mut().take() {
|
||||
id.remove();
|
||||
}
|
||||
ui_close.save_ratios();
|
||||
glib::Propagation::Proceed
|
||||
});
|
||||
|
||||
@@ -626,12 +594,13 @@ fn build_window(app: &adw::Application) {
|
||||
|
||||
/// The sidebar destinations, in order: the row index is the index into this table,
|
||||
/// and each entry is `(stack page name, label, icon)`.
|
||||
const DESTINATIONS: [(&str, &str, &str); 8] = [
|
||||
const DESTINATIONS: [(&str, &str, &str); 9] = [
|
||||
("browser", "My files", "folder-symbolic"),
|
||||
("sharedbyme", "Shared", "emblem-shared-symbolic"),
|
||||
("shared", "Shared with me", "system-users-symbolic"),
|
||||
("locations", "Locations", "drive-harddisk-symbolic"),
|
||||
("devices", "Computers", "computer-symbolic"),
|
||||
("gallery", "Photos", "image-x-generic-symbolic"),
|
||||
("gallery", "Gallery", "image-x-generic-symbolic"),
|
||||
("activity", "Activity", "document-open-recent-symbolic"),
|
||||
("trash", "Trash", "user-trash-symbolic"),
|
||||
("main", "Settings", "emblem-system-symbolic"),
|
||||
@@ -734,7 +703,15 @@ fn reload_current_page(ui: &Rc<Ui>) {
|
||||
let path = ui.browser.path.borrow().clone();
|
||||
refresh_then(ui, RefreshScope::Dir { path }, load_browser);
|
||||
}
|
||||
Some("gallery") => refresh_then(ui, RefreshScope::Photos, |ui| load_gallery(ui, false)),
|
||||
// One scope covers the whole photos view, so Refresh reloads whichever of
|
||||
// the two — album grid or timeline/album — is actually on screen.
|
||||
Some("gallery") => refresh_then(ui, RefreshScope::Photos, |ui| {
|
||||
if ui.gallery.content.visible_child_name().as_deref() == Some("albums") {
|
||||
load_albums(ui);
|
||||
} else {
|
||||
load_gallery(ui, false);
|
||||
}
|
||||
}),
|
||||
Some("trash") => refresh_then(ui, RefreshScope::Trash, load_trash),
|
||||
Some("shared") => {
|
||||
ui.shared.loaded_at.set(None);
|
||||
@@ -748,6 +725,10 @@ fn reload_current_page(ui: &Rc<Ui>) {
|
||||
ui.devices.loaded_at.set(None);
|
||||
load_devices(ui);
|
||||
}
|
||||
Some("locations") => {
|
||||
ui.locations.loaded_at.set(None);
|
||||
load_locations(ui);
|
||||
}
|
||||
Some("activity") => load_activity(ui),
|
||||
_ => {}
|
||||
}
|
||||
@@ -1045,81 +1026,54 @@ fn human_bytes(bytes: u64) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A justified row must fill the content width exactly: tile widths plus the
|
||||
/// gaps between them add up to the width, with nothing left ragged.
|
||||
fn row_width(row: &[(i32, i32)]) -> i32 {
|
||||
let gaps = TILE_GAP * (row.len().saturating_sub(1)) as i32;
|
||||
row.iter().map(|(width, _)| *width).sum::<i32>() + gaps
|
||||
/// The full width one row of `columns` tiles occupies, gaps included.
|
||||
fn row_width(columns: usize, edge: i32) -> i32 {
|
||||
edge * columns as i32 + TILE_GAP * (columns as i32 - 1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn justified_rows_fill_the_width_exactly() {
|
||||
let ratios = [
|
||||
1.5, 0.75, 1.5, 1.33, 0.66, 1.5, 1.0, 2.2, 1.5, 0.8, 1.5, 1.6,
|
||||
];
|
||||
let rows = plan_rows(&ratios, 180.0, 1000.0);
|
||||
|
||||
assert!(
|
||||
rows.len() > 1,
|
||||
"12 photos at 180px should need several rows"
|
||||
);
|
||||
// Every row but the trailing one is justified.
|
||||
for row in &rows[..rows.len() - 1] {
|
||||
assert_eq!(row_width(row), 1000);
|
||||
fn the_grid_spans_the_content_width() {
|
||||
// Whatever the width, the columns plus their gaps land on it (bar the
|
||||
// few px integer division cannot divide), so no ragged right margin.
|
||||
for width in [640, 900, 1000, 1440, 1920, 2560] {
|
||||
let (columns, edge) = plan_grid(TILE_DEFAULT, width);
|
||||
let used = row_width(columns, edge);
|
||||
assert!(
|
||||
used <= width && width - used < columns as i32,
|
||||
"{columns} x {edge}px = {used}px does not span {width}px"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_photo_lands_in_exactly_one_row() {
|
||||
let ratios: Vec<f64> = (0..37).map(|i| 0.5 + f64::from(i % 5) * 0.4).collect();
|
||||
let rows = plan_rows(&ratios, 180.0, 1000.0);
|
||||
let tiles: usize = rows.iter().map(Vec::len).sum();
|
||||
assert_eq!(tiles, ratios.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiles_keep_their_aspect_ratio() {
|
||||
let rows = plan_rows(&[1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5], 180.0, 1000.0);
|
||||
// All but the last tile of a justified row are sized straight from the
|
||||
// ratio, so w/h is the photo's own — no cropping needed to place it.
|
||||
for row in &rows {
|
||||
for (width, height) in &row[..row.len() - 1] {
|
||||
let ratio = f64::from(*width) / f64::from(*height);
|
||||
assert!((ratio - 1.5).abs() < 0.02, "{width}x{height} is not 3:2");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_row_is_not_stretched() {
|
||||
// Two photos can't fill a 1000px row at 180px tall, so they must keep the
|
||||
// target height rather than being blown up to a screen-wide row.
|
||||
let rows = plan_rows(&[1.5, 1.5], 180.0, 1000.0);
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert!(rows[0].iter().all(|(_, height)| *height == 180));
|
||||
assert!(row_width(&rows[0]) < 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_extreme_panorama_cannot_squash_its_row() {
|
||||
// Unclamped, a 20:1 panorama would drag its whole row down to ~40px tall.
|
||||
// Clamped at RATIO_MAX it stays a wide tile in a row of normal height.
|
||||
let rows = plan_rows(&[20.0, 1.5, 1.5], 180.0, 1000.0);
|
||||
let (width, height) = rows[0][0];
|
||||
assert!(height > 120, "row squashed to {height}px");
|
||||
assert!(width <= 1000, "tile overflows the row at {width}px");
|
||||
assert_eq!(row_width(&rows[0]), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_widths_and_no_photos_are_survivable() {
|
||||
assert!(plan_rows(&[], 180.0, 1000.0).is_empty());
|
||||
// Width below the floor is clamped rather than producing zero-px tiles.
|
||||
let rows = plan_rows(&[1.5, 1.5], 180.0, 0.0);
|
||||
fn tiles_stay_near_the_target_size() {
|
||||
// The column count rounds and the edge absorbs the remainder, so a tile
|
||||
// is never more than one gap-and-a-bit away from what was asked for.
|
||||
let (columns, edge) = plan_grid(TILE_DEFAULT, 1920);
|
||||
assert!(columns >= 7, "1920px should hold several 220px tiles");
|
||||
assert!(
|
||||
rows.iter()
|
||||
.flatten()
|
||||
.all(|(width, height)| *width > 0 && *height > 0)
|
||||
(edge - TILE_DEFAULT).abs() < TILE_DEFAULT / 3,
|
||||
"{edge}px is not near the {TILE_DEFAULT}px target"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zooming_in_widens_the_tiles_and_drops_columns() {
|
||||
let (wide_columns, small_edge) = plan_grid(TILE_MIN, 1200);
|
||||
let (few_columns, big_edge) = plan_grid(TILE_MAX, 1200);
|
||||
assert!(wide_columns > few_columns);
|
||||
assert!(big_edge > small_edge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_narrower_than_one_tile_still_gets_a_column() {
|
||||
// A single column at whatever fits, rather than zero columns (which would
|
||||
// divide by zero) or a zero-px tile.
|
||||
let (columns, edge) = plan_grid(TILE_DEFAULT, 40);
|
||||
assert_eq!(columns, 1);
|
||||
assert!(edge > 0);
|
||||
let (columns, edge) = plan_grid(TILE_DEFAULT, 0);
|
||||
assert_eq!(columns, 1);
|
||||
assert!(edge > 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! The Albums view of the Photos page: a grid of album covers, and the album
|
||||
//! that opens when one is clicked.
|
||||
//!
|
||||
//! An open album reuses the gallery wholesale — same square grid, same
|
||||
//! on-demand thumbnails, same lightbox — by parking the album in
|
||||
//! [`GalleryState::album`] and letting [`load_gallery`] page
|
||||
//! [`Request::AlbumPhotos`] instead of the timeline. Only the header and the
|
||||
//! filter bar differ, because an album is served whole rather than filtered.
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// Edge length in px of an album cover in the grid. Bigger than a timeline tile:
|
||||
/// there are far fewer albums than photos, and the cover is the only thing
|
||||
/// identifying one at a glance.
|
||||
const COVER_EDGE: i32 = 200;
|
||||
|
||||
/// Show the Albums grid and (re)load it. Called when the Albums toggle goes on.
|
||||
pub(crate) fn show_albums(ui: &Rc<Ui>) {
|
||||
close_album(ui);
|
||||
ui.gallery.content.set_visible_child_name("albums");
|
||||
load_albums(ui);
|
||||
}
|
||||
|
||||
/// Ask the daemon for the album listing and rebuild the grid.
|
||||
pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
||||
if ui.gallery.albums_loading.get() {
|
||||
return;
|
||||
}
|
||||
ui.gallery.albums_loading.set(true);
|
||||
albums_status(
|
||||
ui,
|
||||
"view-grid-symbolic",
|
||||
"Loading albums…",
|
||||
"Reading your Proton Drive albums.",
|
||||
);
|
||||
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.gallery.albums_loading.set(false);
|
||||
match result {
|
||||
Ok(Ok(Response::Albums {
|
||||
available: false, ..
|
||||
})) => albums_status(
|
||||
&ui,
|
||||
"image-missing-symbolic",
|
||||
"No photo library",
|
||||
"This Proton account doesn't have Photos enabled.",
|
||||
),
|
||||
Ok(Ok(Response::Albums { items, .. })) if items.is_empty() => albums_status(
|
||||
&ui,
|
||||
"view-grid-symbolic",
|
||||
"No albums",
|
||||
"Albums you create in Proton Photos appear here.",
|
||||
),
|
||||
Ok(Ok(Response::Albums { items, .. })) => {
|
||||
fill_albums(&ui, &items);
|
||||
ui.gallery.albums_stack.set_visible_child_name("grid");
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't load albums", &message, kind);
|
||||
albums_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Couldn't load albums",
|
||||
&message,
|
||||
);
|
||||
}
|
||||
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => albums_status(
|
||||
&ui,
|
||||
"network-offline-symbolic",
|
||||
"Not connected",
|
||||
"The Proton Drive mount service didn't respond.",
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Replace the grid's cards with `albums`, in the order the daemon gave them
|
||||
/// (newest activity first).
|
||||
fn fill_albums(ui: &Rc<Ui>, albums: &[AlbumInfo]) {
|
||||
while let Some(child) = ui.gallery.albums.first_child() {
|
||||
ui.gallery.albums.remove(&child);
|
||||
}
|
||||
for album in albums {
|
||||
ui.gallery.albums.append(&album_card(ui, album));
|
||||
}
|
||||
// The covers are ordinary photos as far as the daemon is concerned, so the
|
||||
// batch that fills them is the gallery's own.
|
||||
schedule_thumbs(ui);
|
||||
}
|
||||
|
||||
/// One album as a clickable card: its cover, its name, and how many photos it
|
||||
/// holds. An album shared with us says so — it lives on someone else's volume,
|
||||
/// and that is worth knowing before opening it.
|
||||
fn album_card(ui: &Rc<Ui>, album: &AlbumInfo) -> gtk4::Button {
|
||||
let picture = gtk4::Picture::builder()
|
||||
// Fills and crops the cover square, like a timeline tile.
|
||||
.content_fit(gtk4::ContentFit::Cover)
|
||||
.can_shrink(true)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
picture.add_css_class("photo-thumb");
|
||||
|
||||
let placeholder = gtk4::Image::builder()
|
||||
.icon_name("view-grid-symbolic")
|
||||
.pixel_size(32)
|
||||
.halign(gtk4::Align::Center)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
placeholder.add_css_class("photo-placeholder");
|
||||
|
||||
let cover = gtk4::Overlay::new();
|
||||
cover.set_child(Some(&placeholder));
|
||||
cover.add_overlay(&picture);
|
||||
cover.set_size_request(COVER_EDGE, COVER_EDGE);
|
||||
cover.set_overflow(gtk4::Overflow::Hidden);
|
||||
cover.add_css_class("album-cover");
|
||||
|
||||
let name = gtk4::Label::builder()
|
||||
.label(&album.name)
|
||||
.halign(gtk4::Align::Start)
|
||||
.xalign(0.0)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
name.add_css_class("heading");
|
||||
|
||||
let count = gtk4::Label::builder()
|
||||
.label(album_subtitle(album))
|
||||
.halign(gtk4::Align::Start)
|
||||
.xalign(0.0)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
count.add_css_class("dim-label");
|
||||
count.add_css_class("caption");
|
||||
|
||||
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||
card.append(&cover);
|
||||
card.append(&name);
|
||||
card.append(&count);
|
||||
|
||||
let button = gtk4::Button::builder().child(&card).build();
|
||||
button.add_css_class("flat");
|
||||
button.add_css_class("album-card");
|
||||
button.set_tooltip_text(Some(&album.name));
|
||||
|
||||
if let Some(uid) = album.cover_uid.clone() {
|
||||
want_cover(ui, uid, &picture);
|
||||
}
|
||||
|
||||
let ui_open = ui.clone();
|
||||
let album = album.clone();
|
||||
button.connect_clicked(move |_| open_album(&ui_open, album.clone()));
|
||||
button
|
||||
}
|
||||
|
||||
/// "12 photos", plus where the album came from when it isn't ours.
|
||||
fn album_subtitle(album: &AlbumInfo) -> String {
|
||||
let photos = match album.photo_count {
|
||||
1 => "1 photo".to_string(),
|
||||
n => format!("{n} photos"),
|
||||
};
|
||||
if album.shared {
|
||||
format!("{photos} · shared with me")
|
||||
} else {
|
||||
photos
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue an album cover for the gallery's own thumbnail machinery: same batching,
|
||||
/// same texture cache, same decode pacing as a timeline tile.
|
||||
fn want_cover(ui: &Rc<Ui>, uid: String, picture: >k4::Picture) {
|
||||
if let Some(texture) = ui.gallery.photo_tex.borrow().get(&uid) {
|
||||
picture.set_paintable(Some(texture));
|
||||
return;
|
||||
}
|
||||
if ui.gallery.photo_nothumb.borrow().contains(&uid) {
|
||||
return;
|
||||
}
|
||||
ui.gallery
|
||||
.thumb_wanted
|
||||
.borrow_mut()
|
||||
.insert(uid.clone(), picture.clone());
|
||||
let mut queue = ui.gallery.thumb_queue.borrow_mut();
|
||||
if !queue.contains(&uid) {
|
||||
queue.push_back(uid);
|
||||
}
|
||||
}
|
||||
|
||||
/// Open one album in the gallery: the timeline view, paged from the album
|
||||
/// instead, with the filters that don't apply to it out of the way.
|
||||
pub(crate) fn open_album(ui: &Rc<Ui>, album: AlbumInfo) {
|
||||
ui.gallery.title.set_label(&album.name);
|
||||
ui.gallery.subtitle.set_label(&album_subtitle(&album));
|
||||
ui.gallery.subtitle.set_visible(true);
|
||||
*ui.gallery.album.borrow_mut() = Some(album);
|
||||
|
||||
// An album page carries no kind or date filter, and Upload targets the
|
||||
// timeline rather than an album — hide those rather than offer controls that
|
||||
// would silently do something else. The Photos/Albums switcher goes too: an
|
||||
// album is a level below it, and back is the way out.
|
||||
ui.gallery.filters.set_visible(false);
|
||||
ui.gallery.view_switch.set_visible(false);
|
||||
ui.gallery.upload.set_visible(false);
|
||||
ui.gallery.back.set_visible(true);
|
||||
// A stale kind/date filter would otherwise be sent with the album request.
|
||||
ui.gallery.kind.set(None);
|
||||
ui.gallery.range.set(None);
|
||||
|
||||
ui.gallery.content.set_visible_child_name("timeline");
|
||||
load_gallery(ui, false);
|
||||
}
|
||||
|
||||
/// Leave an open album, restoring the timeline's header and filters. A no-op
|
||||
/// when no album is open, so callers can use it as "make sure we're not in one".
|
||||
pub(crate) fn close_album(ui: &Rc<Ui>) {
|
||||
if ui.gallery.album.borrow_mut().take().is_none() {
|
||||
return;
|
||||
}
|
||||
ui.gallery.title.set_label("Gallery");
|
||||
ui.gallery.filters.set_visible(true);
|
||||
ui.gallery.view_switch.set_visible(true);
|
||||
ui.gallery.upload.set_visible(true);
|
||||
ui.gallery.back.set_visible(false);
|
||||
}
|
||||
|
||||
/// Show the album grid's own status page (loading, empty, or an error).
|
||||
fn albums_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) {
|
||||
ui.gallery.albums_status.set_icon_name(Some(icon));
|
||||
ui.gallery.albums_status.set_title(title);
|
||||
ui.gallery.albums_status.set_description(Some(description));
|
||||
ui.gallery.albums_stack.set_visible_child_name("status");
|
||||
}
|
||||
|
||||
/// Wire the Photos/Albums switcher and the back button. The switcher swaps the
|
||||
/// whole content area; back leaves an open album for the grid it came from.
|
||||
pub(crate) fn wire_albums(ui: &Rc<Ui>) {
|
||||
let ui_albums = ui.clone();
|
||||
ui.gallery.albums_btn.clone().connect_toggled(move |btn| {
|
||||
if btn.is_active() {
|
||||
show_albums(&ui_albums);
|
||||
}
|
||||
});
|
||||
|
||||
// Only the button being switched *on* acts: the group fires `toggled` for
|
||||
// the one going off too, and acting on both would load twice.
|
||||
let ui_photos = ui.clone();
|
||||
ui.gallery.photos_btn.clone().connect_toggled(move |btn| {
|
||||
if !btn.is_active() {
|
||||
return;
|
||||
}
|
||||
// The timeline is reloaded rather than restored, because an open album
|
||||
// left the model holding its own photos.
|
||||
close_album(&ui_photos);
|
||||
ui_photos.gallery.content.set_visible_child_name("timeline");
|
||||
load_gallery(&ui_photos, false);
|
||||
});
|
||||
|
||||
let ui_back = ui.clone();
|
||||
ui.gallery.back.clone().connect_clicked(move |_| {
|
||||
show_albums(&ui_back);
|
||||
});
|
||||
}
|
||||
@@ -1442,6 +1442,7 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
||||
cached: false,
|
||||
uid: h.uid.clone(),
|
||||
path: h.path.clone(),
|
||||
role: String::new(),
|
||||
})
|
||||
.collect();
|
||||
entries.sort_by(|a, b| {
|
||||
|
||||
@@ -9,6 +9,8 @@ pub(crate) struct DevicesState {
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
||||
pub(crate) sync_group: adw::PreferencesGroup,
|
||||
/// The single "This computer" summary row. The per-folder rows moved to the
|
||||
/// Locations page, which owns every local path.
|
||||
pub(crate) sync_rows: RefCell<Vec<gtk4::Widget>>,
|
||||
/// "Rename" action in the "This computer" header. Insensitive until the
|
||||
/// device list identifies this machine's own device.
|
||||
@@ -25,7 +27,8 @@ pub(crate) struct DevicesState {
|
||||
pub(crate) struct DevicesWidgets {
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
/// "This computer" — the local folders synced to this machine's device.
|
||||
/// "This computer" — this machine's device identity and a pointer to its
|
||||
/// folders on the Locations page.
|
||||
pub(crate) sync_group: adw::PreferencesGroup,
|
||||
/// "Rename" in the "This computer" header — renames this machine's device.
|
||||
pub(crate) rename_this: gtk4::Button,
|
||||
@@ -33,7 +36,6 @@ pub(crate) struct DevicesWidgets {
|
||||
/// machine's own device is deliberately not among them; see
|
||||
/// [`repaint_devices`].
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
pub(crate) add_folder: gtk4::Button,
|
||||
/// "Restore Folders" — re-attach this device's remote folders to local
|
||||
/// directories after adopting it on a new machine.
|
||||
pub(crate) restore: gtk4::Button,
|
||||
@@ -54,11 +56,6 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
||||
titles.set_hexpand(true);
|
||||
titles.append(&title);
|
||||
|
||||
let add_folder = gtk4::Button::builder()
|
||||
.label("Add Folder")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
add_folder.add_css_class("flat");
|
||||
let restore = gtk4::Button::builder()
|
||||
.label("Restore Folders")
|
||||
.tooltip_text("Sync this computer's Drive folders back to local directories")
|
||||
@@ -71,18 +68,14 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
||||
header.append(&titles);
|
||||
header.append(&refresh);
|
||||
header.append(&restore);
|
||||
header.append(&add_folder);
|
||||
|
||||
// The description spells out what the per-row On-demand switch does to the
|
||||
// local copy, because the switch itself can't: turning it on *deletes* the
|
||||
// files from this disk, which is not something to discover afterwards.
|
||||
// This page is about device *identity* — which computer this is, which other
|
||||
// computers back up to the account, and how to adopt one. The folders
|
||||
// themselves are local paths, so they live on the Locations page; keeping a
|
||||
// second copy of that list here would be two places to change the same mode.
|
||||
let sync_group = adw::PreferencesGroup::builder()
|
||||
.title("This computer")
|
||||
.description(
|
||||
"Local folders backed up to this device. Synced folders keep a full copy on \
|
||||
this disk; on-demand folders keep the files in Proton Drive only and fetch \
|
||||
them as you open them.",
|
||||
)
|
||||
.description("The device this machine backs up to.")
|
||||
.build();
|
||||
// Rename control for *this* machine's device, in the section header. The
|
||||
// current device is filtered out of "Other computers", so this is the only
|
||||
@@ -145,7 +138,6 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
||||
sync_group,
|
||||
rename_this,
|
||||
group,
|
||||
add_folder,
|
||||
restore,
|
||||
retry,
|
||||
refresh,
|
||||
@@ -153,20 +145,13 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
||||
)
|
||||
}
|
||||
|
||||
/// Install the Devices page's retry button and the "Add Folder" action.
|
||||
pub(crate) fn wire_devices(
|
||||
ui: &Rc<Ui>,
|
||||
retry: >k4::Button,
|
||||
add_folder: >k4::Button,
|
||||
restore: >k4::Button,
|
||||
) {
|
||||
/// Install the Devices page's retry button and the "Restore Folders" action.
|
||||
pub(crate) fn wire_devices(ui: &Rc<Ui>, retry: >k4::Button, restore: >k4::Button) {
|
||||
let ui_retry = ui.clone();
|
||||
retry.connect_clicked(move |_| {
|
||||
service::restart();
|
||||
load_devices(&ui_retry);
|
||||
});
|
||||
let ui_add = ui.clone();
|
||||
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
|
||||
let ui_restore = ui.clone();
|
||||
restore.connect_clicked(move |_| prompt_restore_folders(&ui_restore));
|
||||
let ui_ren = ui.clone();
|
||||
@@ -224,9 +209,9 @@ pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Daemon reachable: show the list and paint the synced-folders section.
|
||||
// Daemon reachable: show the list and paint the "This computer" section.
|
||||
ui.devices.content.set_visible_child_name("list");
|
||||
repaint_sync_folders(&ui, &folders);
|
||||
repaint_this_computer(&ui, &folders);
|
||||
|
||||
let devices = match devices_reply {
|
||||
Ok(Ok(Response::Devices { items })) => items,
|
||||
@@ -239,35 +224,6 @@ pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Refresh just the synced-folders section, with no status flash, no spinner and
|
||||
/// no devices round-trip. Driven by the periodic tick while the Devices page is
|
||||
/// on screen: a folder's sync progress is only live if something re-reads it.
|
||||
///
|
||||
/// A hiccup leaves the page as it is — the next tick tries again, and a real
|
||||
/// outage is reported by [`load_devices`] on the next visit.
|
||||
pub(crate) fn refresh_sync_folders(ui: &Rc<Ui>) {
|
||||
if ui.devices.inflight.get() {
|
||||
return;
|
||||
}
|
||||
ui.devices.inflight.set(true);
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSyncFolders);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.devices.inflight.set(false);
|
||||
let Ok(Ok(Response::SyncFolders { items })) = result else {
|
||||
return;
|
||||
};
|
||||
// The page may have been navigated away from, or collapsed to a status
|
||||
// view, while the request was in flight.
|
||||
if ui.stack.visible_child_name().as_deref() == Some("devices")
|
||||
&& ui.devices.content.visible_child_name().as_deref() == Some("list")
|
||||
{
|
||||
repaint_sync_folders(&ui, &items);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The daemon didn't answer the Devices page.
|
||||
pub(crate) fn devices_unreachable(ui: &Rc<Ui>) {
|
||||
if service::is_failed() || !service::is_active() {
|
||||
@@ -398,137 +354,53 @@ pub(crate) fn device_subtitle(dev: &DeviceInfo) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the "This computer" section from a fresh synced-folders listing.
|
||||
pub(crate) fn repaint_sync_folders(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
|
||||
/// Rebuild the "This computer" section: one row naming the device this machine
|
||||
/// backs up to, and how many folders it carries.
|
||||
///
|
||||
/// The per-folder rows (mode switch, sync now, remove) live on the Locations
|
||||
/// page. They are local paths, and Locations is the one page that owns those;
|
||||
/// two lists of the same folders would be two places to flip the same switch.
|
||||
pub(crate) fn repaint_this_computer(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
|
||||
for row in ui.devices.sync_rows.borrow_mut().drain(..) {
|
||||
ui.devices.sync_group.remove(&row);
|
||||
}
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("This computer")
|
||||
.subtitle(this_computer_subtitle(folders))
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||
let manage = gtk4::Button::builder()
|
||||
.label("Locations")
|
||||
.tooltip_text("Manage this computer's folders and mountpoint")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
manage.add_css_class("flat");
|
||||
let ui_go = ui.clone();
|
||||
manage.connect_clicked(move |_| ui_go.stack.set_visible_child_name("locations"));
|
||||
row.add_suffix(&manage);
|
||||
ui.devices.sync_group.add(&row);
|
||||
*ui.devices.sync_rows.borrow_mut() = vec![row.upcast()];
|
||||
}
|
||||
|
||||
/// How many folders this machine backs up, and whether any of them needs
|
||||
/// attention — the one fact worth surfacing away from the folder list itself.
|
||||
pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
|
||||
if folders.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("No synced folders")
|
||||
.subtitle("Add a folder to back it up to this device.")
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("folder-symbolic"));
|
||||
ui.devices.sync_group.add(&row);
|
||||
*ui.devices.sync_rows.borrow_mut() = vec![row.upcast()];
|
||||
return;
|
||||
return "No folders backed up yet — add one under Locations.".to_string();
|
||||
}
|
||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||
for f in folders {
|
||||
// A running pass describes itself ("Uploading photo.jpg — 12 of 40");
|
||||
// otherwise fall back to the folder's resting state.
|
||||
let status = match &f.progress {
|
||||
Some(p) => sync_progress_label(p),
|
||||
None => sync_state_label(&f.state).to_string(),
|
||||
};
|
||||
// A queued switch is where the folder is *going*, which is what the user
|
||||
// just asked for and is waiting to see happen — so it leads the subtitle
|
||||
// instead of the mode the folder is still technically in.
|
||||
let subtitle = match (f.pending_mode.as_deref(), f.mode.as_str()) {
|
||||
(Some("ondemand"), _) => format!("Going on-demand · {status}"),
|
||||
(Some(_), _) => format!("Switching to synced · {status}"),
|
||||
(None, "ondemand") => format!("On-demand · {status}"),
|
||||
(None, _) => format!("Synced · {status}"),
|
||||
};
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&f.local_path)
|
||||
.subtitle(&subtitle)
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("folder-symbolic"));
|
||||
let id = f.id;
|
||||
|
||||
// A pass that knows how far it has got shows it: the subtitle alone
|
||||
// ("syncing photo.jpg — 12 of 40") never conveys that the end is near. This
|
||||
// holds for scanning as much as applying — a big folder spends minutes there
|
||||
// — but a folder syncing for the first time has no estimate to draw against
|
||||
// (`total == 0`), and this row is repainted on the 2s tick, too slow for a
|
||||
// pulse to read as motion. So it stays text-only until real counts exist.
|
||||
if let Some(p) = &f.progress
|
||||
&& p.total > 0
|
||||
{
|
||||
let bar = gtk4::ProgressBar::builder()
|
||||
.fraction((p.done as f64 / p.total.max(p.done) as f64).min(1.0))
|
||||
.valign(gtk4::Align::Center)
|
||||
.width_request(120)
|
||||
.build();
|
||||
row.add_suffix(&bar);
|
||||
}
|
||||
|
||||
// Sync now — only meaningful for mirror folders (on-demand has no local
|
||||
// copy to reconcile). The engine also syncs on file changes and on a
|
||||
// 120s poll; this is the "don't wait" button.
|
||||
if f.mode != "ondemand" {
|
||||
let sync_now = gtk4::Button::builder()
|
||||
.icon_name("view-refresh-symbolic")
|
||||
.tooltip_text("Sync this folder now")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
sync_now.add_css_class("flat");
|
||||
let ui_sync = ui.clone();
|
||||
sync_now.connect_clicked(move |_| {
|
||||
let rx = spawn_request(
|
||||
ui_sync.dirs.control_socket(),
|
||||
Request::SyncNow { id: Some(id) },
|
||||
);
|
||||
let ui_sync = ui_sync.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => toast(&ui_sync, "Syncing folder…"),
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui_sync, "Couldn't sync", &message, kind)
|
||||
}
|
||||
_ => toast_error(
|
||||
&ui_sync,
|
||||
"Couldn't sync",
|
||||
"The mount service didn't respond.",
|
||||
),
|
||||
}
|
||||
});
|
||||
});
|
||||
row.add_suffix(&sync_now);
|
||||
}
|
||||
|
||||
// On-demand toggle: off = full local copy (mirror), on = FUSE mount that
|
||||
// frees the disk. Set the state before wiring the handler so painting the
|
||||
// current mode doesn't fire a spurious request.
|
||||
//
|
||||
// A queued switch paints as already flipped: the daemon accepted the
|
||||
// request and will act on it, so snapping the switch back to the current
|
||||
// mode would read as "it didn't take" and invite the user to toggle again.
|
||||
let target = f.pending_mode.as_deref().unwrap_or(&f.mode);
|
||||
let ondemand = gtk4::Switch::builder()
|
||||
.tooltip_text(
|
||||
"On-demand: free this disk by keeping the files in Proton Drive only, \
|
||||
fetching each as you open it. Turn off to download them back and keep a \
|
||||
full local copy.",
|
||||
)
|
||||
.valign(gtk4::Align::Center)
|
||||
.active(target == "ondemand")
|
||||
.build();
|
||||
let ui_mode = ui.clone();
|
||||
ondemand.connect_state_set(move |_, on| {
|
||||
set_sync_folder_mode(&ui_mode, id, if on { "ondemand" } else { "mirror" });
|
||||
glib::Propagation::Proceed
|
||||
});
|
||||
row.add_suffix(&ondemand);
|
||||
|
||||
let remove = gtk4::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.tooltip_text("Stop syncing this folder")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
remove.add_css_class("flat");
|
||||
let ui_rm = ui.clone();
|
||||
let path = f.local_path.clone();
|
||||
// The folder's *current* mode, not a queued one: a switch that hasn't
|
||||
// landed yet has not moved the files anywhere.
|
||||
let ondemand = f.mode == "ondemand";
|
||||
remove.connect_clicked(move |_| prompt_remove_sync_folder(&ui_rm, id, &path, ondemand));
|
||||
row.add_suffix(&remove);
|
||||
ui.devices.sync_group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
let count = match folders.len() {
|
||||
1 => "1 folder backed up".to_string(),
|
||||
n => format!("{n} folders backed up"),
|
||||
};
|
||||
let attention = folders
|
||||
.iter()
|
||||
.filter(|f| f.state == "error" || f.state == "conflict")
|
||||
.count();
|
||||
match attention {
|
||||
0 => format!("{count} · manage them under Locations"),
|
||||
1 => format!("{count} · 1 needs attention"),
|
||||
n => format!("{count} · {n} need attention"),
|
||||
}
|
||||
*ui.devices.sync_rows.borrow_mut() = rows;
|
||||
}
|
||||
|
||||
/// Human label for a synced folder's `state` column.
|
||||
@@ -592,7 +464,7 @@ pub(crate) fn prompt_add_sync_folder(ui: &Rc<Ui>) {
|
||||
// device and creating the remote folder is off-socket network
|
||||
// work. A fixed delay here either fires too early (empty list)
|
||||
// or too late (row flashes in). Instead let the periodic
|
||||
// `refresh_sync_folders` tick pick the row up whenever it
|
||||
// `refresh_locations` tick pick the row up whenever it
|
||||
// actually lands, which is what keeps a running pass live too.
|
||||
toast(&ui, "Syncing folder…");
|
||||
}
|
||||
@@ -942,3 +814,47 @@ pub(crate) fn run_devices_mutation(
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn folder(state: &str) -> SyncFolderInfo {
|
||||
SyncFolderInfo {
|
||||
id: 1,
|
||||
local_path: "/home/u/Documents".into(),
|
||||
remote_uid: "vol~link".into(),
|
||||
mode: "mirror".into(),
|
||||
state: state.into(),
|
||||
last_sync: 0,
|
||||
pending_mode: None,
|
||||
progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_machine_with_no_folders_is_told_where_to_add_one() {
|
||||
assert_eq!(
|
||||
this_computer_subtitle(&[]),
|
||||
"No folders backed up yet — add one under Locations."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folders_needing_attention_are_counted_not_hidden() {
|
||||
// The per-folder rows moved to Locations, so this row is the only place
|
||||
// the Computers page can surface that something is wrong.
|
||||
assert_eq!(
|
||||
this_computer_subtitle(&[folder("idle"), folder("idle")]),
|
||||
"2 folders backed up · manage them under Locations"
|
||||
);
|
||||
assert_eq!(
|
||||
this_computer_subtitle(&[folder("idle"), folder("error")]),
|
||||
"2 folders backed up · 1 needs attention"
|
||||
);
|
||||
assert_eq!(
|
||||
this_computer_subtitle(&[folder("conflict"), folder("error")]),
|
||||
"2 folders backed up · 2 need attention"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
//! The Locations page: every local place Proton Drive occupies on this machine.
|
||||
//!
|
||||
//! One row per [`MountSpec`] from [`Request::ListLocations`] — the primary
|
||||
//! `~/ProtonDrive` mount, plus each folder this computer backs up, whether it is
|
||||
//! a mirrored local directory or an on-demand FUSE session. The page is called
|
||||
//! *Locations* rather than *Mounts* because a mirror folder is a plain directory
|
||||
//! with no FUSE session behind it (mount-architecture.md §4).
|
||||
//!
|
||||
//! The device rows drive the same control requests the Computers page used to:
|
||||
//! mode switch, sync now, remove. What is new here is that the primary mount is
|
||||
//! listed alongside them, with the mountpoint chooser that used to live in
|
||||
//! Settings.
|
||||
|
||||
use crate::*;
|
||||
|
||||
pub(crate) struct LocationsState {
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
||||
pub(crate) inflight: Cell<bool>,
|
||||
pub(crate) loaded_at: Cell<Option<Instant>>,
|
||||
}
|
||||
|
||||
/// Widgets the Locations page's load/repaint touch.
|
||||
pub(crate) struct LocationsWidgets {
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) refresh: gtk4::Button,
|
||||
pub(crate) add_folder: gtk4::Button,
|
||||
}
|
||||
|
||||
pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
|
||||
let title = gtk4::Label::builder()
|
||||
.label("Locations")
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
title.add_css_class("title-2");
|
||||
|
||||
let add_folder = gtk4::Button::builder()
|
||||
.label("Add Folder")
|
||||
.tooltip_text("Back a local folder up to this computer's Proton Drive device")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
add_folder.add_css_class("flat");
|
||||
let refresh = refresh_button();
|
||||
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
||||
titles.set_hexpand(true);
|
||||
titles.append(&title);
|
||||
header.append(&titles);
|
||||
header.append(&refresh);
|
||||
header.append(&add_folder);
|
||||
// Clamped to the same width as the rows below, or the page title floats off
|
||||
// to the left of the list it names.
|
||||
let header_clamp = adw::Clamp::builder().child(&header).build();
|
||||
|
||||
// Same warning the Computers page carried, for the same reason: the
|
||||
// on-demand switch removes the local copy, which is not a thing to discover
|
||||
// after the fact.
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title("On this computer")
|
||||
.description(
|
||||
"Where Proton Drive lives on this machine. Synced folders keep a full copy on \
|
||||
this disk; on-demand folders keep the files in Proton Drive only and fetch \
|
||||
them as you open them.",
|
||||
)
|
||||
.build();
|
||||
|
||||
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
groups.append(&group);
|
||||
let clamp = adw::Clamp::builder().child(&groups).build();
|
||||
// Never scroll sideways: a location's title is a full path, and letting the
|
||||
// row grow to fit one pushes the controls at its end off screen. Constrained,
|
||||
// the path ellipsizes and the switch/remove/open stay reachable.
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
|
||||
let retry = gtk4::Button::builder()
|
||||
.label("Retry")
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
retry.add_css_class("pill");
|
||||
retry.add_css_class("suggested-action");
|
||||
retry.set_visible(false);
|
||||
let status = adw::StatusPage::builder()
|
||||
.icon_name("drive-harddisk-symbolic")
|
||||
.title("Loading…")
|
||||
.child(&retry)
|
||||
.build();
|
||||
// A bare `StatusPage` asks for more width than a half-screen window has, and
|
||||
// a `Stack` is as wide as its widest child — so an unwrapped status page
|
||||
// silently widens the *list* page too, pushing each row's controls off the
|
||||
// right edge. Scrolling it (never horizontally) caps that demand.
|
||||
let status_scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.child(&status)
|
||||
.build();
|
||||
|
||||
let content = gtk4::Stack::new();
|
||||
content.add_named(&scroll, Some("list"));
|
||||
content.add_named(&status_scroll, Some("status"));
|
||||
|
||||
let page = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
page.set_margin_top(18);
|
||||
page.set_margin_bottom(18);
|
||||
page.set_margin_start(18);
|
||||
page.set_margin_end(18);
|
||||
page.append(&header_clamp);
|
||||
page.append(&content);
|
||||
|
||||
let widgets = LocationsWidgets {
|
||||
content: content.clone(),
|
||||
status: status.clone(),
|
||||
group: group.clone(),
|
||||
retry: retry.clone(),
|
||||
refresh,
|
||||
add_folder,
|
||||
};
|
||||
(page.upcast(), widgets)
|
||||
}
|
||||
|
||||
pub(crate) fn wire_locations(ui: &Rc<Ui>, retry: >k4::Button, add_folder: >k4::Button) {
|
||||
let ui_retry = ui.clone();
|
||||
retry.connect_clicked(move |_| {
|
||||
ui_retry.locations.loaded_at.set(None);
|
||||
load_locations(&ui_retry);
|
||||
});
|
||||
let ui_add = ui.clone();
|
||||
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
|
||||
}
|
||||
|
||||
/// Show a status page in place of the locations list.
|
||||
pub(crate) fn locations_status(
|
||||
ui: &Rc<Ui>,
|
||||
icon: &str,
|
||||
title: &str,
|
||||
description: &str,
|
||||
retry: bool,
|
||||
) {
|
||||
ui.locations.status.set_icon_name(Some(icon));
|
||||
ui.locations.status.set_title(title);
|
||||
ui.locations.status.set_description(Some(description));
|
||||
ui.locations.retry.set_visible(retry);
|
||||
ui.locations.content.set_visible_child_name("status");
|
||||
}
|
||||
|
||||
/// Fetch every local location and repaint the list.
|
||||
pub(crate) fn load_locations(ui: &Rc<Ui>) {
|
||||
if ui.locations.inflight.get() {
|
||||
return;
|
||||
}
|
||||
ui.locations.inflight.set(true);
|
||||
locations_status(
|
||||
ui,
|
||||
"drive-harddisk-symbolic",
|
||||
"Loading…",
|
||||
"Reading this computer's Proton Drive locations.",
|
||||
false,
|
||||
);
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListLocations);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
ui.locations.inflight.set(false);
|
||||
match result {
|
||||
Ok(Ok(Response::Locations { items })) => {
|
||||
ui.locations.content.set_visible_child_name("list");
|
||||
repaint_locations(&ui, &items);
|
||||
ui.locations.loaded_at.set(Some(Instant::now()));
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) => {
|
||||
ui.locations.loaded_at.set(None);
|
||||
locations_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Unavailable",
|
||||
&message,
|
||||
true,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
ui.locations.loaded_at.set(None);
|
||||
locations_unreachable(&ui);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Refresh the rows with no status flash and no spinner, from the periodic tick:
|
||||
/// a sync pass's progress is only live if something re-reads it.
|
||||
pub(crate) fn refresh_locations(ui: &Rc<Ui>) {
|
||||
if ui.locations.inflight.get() {
|
||||
return;
|
||||
}
|
||||
ui.locations.inflight.set(true);
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListLocations);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.locations.inflight.set(false);
|
||||
let Ok(Ok(Response::Locations { items })) = result else {
|
||||
return;
|
||||
};
|
||||
// The page may have been navigated away from, or collapsed to a status
|
||||
// view, while the request was in flight.
|
||||
if ui.stack.visible_child_name().as_deref() == Some("locations")
|
||||
&& ui.locations.content.visible_child_name().as_deref() == Some("list")
|
||||
{
|
||||
repaint_locations(&ui, &items);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The daemon didn't answer the Locations page.
|
||||
pub(crate) fn locations_unreachable(ui: &Rc<Ui>) {
|
||||
if service::is_failed() || !service::is_active() {
|
||||
locations_status(
|
||||
ui,
|
||||
"network-offline-symbolic",
|
||||
"Not connected",
|
||||
"The Proton Drive mount service isn't running.",
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
locations_status(
|
||||
ui,
|
||||
"folder-remote-symbolic",
|
||||
"Connecting…",
|
||||
"Waiting for the Proton Drive mount service to come up.",
|
||||
false,
|
||||
);
|
||||
let ui = ui.clone();
|
||||
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
|
||||
if ui.stack.visible_child_name().as_deref() == Some("locations") {
|
||||
load_locations(&ui);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn repaint_locations(ui: &Rc<Ui>, items: &[MountSpec]) {
|
||||
for row in ui.locations.rows.borrow_mut().drain(..) {
|
||||
ui.locations.group.remove(&row);
|
||||
}
|
||||
if items.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("No locations")
|
||||
.subtitle("The mount service hasn't reported a mountpoint yet.")
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("drive-harddisk-symbolic"));
|
||||
ui.locations.group.add(&row);
|
||||
*ui.locations.rows.borrow_mut() = vec![row.upcast()];
|
||||
return;
|
||||
}
|
||||
|
||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||
for spec in items {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&spec.local_path)
|
||||
.subtitle(location_subtitle(spec))
|
||||
// One line each: a wrapped path would reflow the whole list every
|
||||
// time a sync state changed under it.
|
||||
.title_lines(1)
|
||||
.subtitle_lines(1)
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name(location_icon(&spec.kind)));
|
||||
|
||||
// A read-only location cannot be written through even where the files are
|
||||
// visible; saying so on the row is cheaper than letting the user find out
|
||||
// from a save dialog.
|
||||
if spec.access == MountAccess::Ro {
|
||||
let badge = gtk4::Label::new(Some("Read-only"));
|
||||
badge.add_css_class("dim-label");
|
||||
badge.add_css_class("caption");
|
||||
badge.set_valign(gtk4::Align::Center);
|
||||
row.add_suffix(&badge);
|
||||
}
|
||||
|
||||
// Same rule as the Computers page: a first pass has no estimate to draw
|
||||
// against, so the bar appears only once real counts exist.
|
||||
if let Some(p) = &spec.progress
|
||||
&& p.total > 0
|
||||
{
|
||||
let bar = gtk4::ProgressBar::builder()
|
||||
.fraction((p.done as f64 / p.total.max(p.done) as f64).min(1.0))
|
||||
.valign(gtk4::Align::Center)
|
||||
.width_request(120)
|
||||
.build();
|
||||
row.add_suffix(&bar);
|
||||
}
|
||||
|
||||
match &spec.kind {
|
||||
MountKind::MyFiles => add_my_files_controls(ui, &row),
|
||||
MountKind::Device { sync_folder_id } => {
|
||||
add_device_controls(ui, &row, spec, *sync_folder_id)
|
||||
}
|
||||
// A standalone shared mount has no local mode to switch and is not
|
||||
// this device's to remove.
|
||||
MountKind::Shared { .. } => {}
|
||||
}
|
||||
|
||||
let open = gtk4::Button::builder()
|
||||
.icon_name("folder-open-symbolic")
|
||||
.tooltip_text("Open this folder")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
open.add_css_class("flat");
|
||||
let path = spec.local_path.clone();
|
||||
open.connect_clicked(move |_| open_path(&path));
|
||||
row.add_suffix(&open);
|
||||
|
||||
ui.locations.group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
}
|
||||
*ui.locations.rows.borrow_mut() = rows;
|
||||
}
|
||||
|
||||
/// The primary mount's only control: where it lives. Changing it rewrites config
|
||||
/// and offers a service restart, which is why it stays a single prompt shared
|
||||
/// with the (now removed) Settings row.
|
||||
fn add_my_files_controls(ui: &Rc<Ui>, row: &adw::ActionRow) {
|
||||
let change = gtk4::Button::builder()
|
||||
.label("Change")
|
||||
.tooltip_text("Choose a different folder for the Proton Drive mount")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
change.add_css_class("flat");
|
||||
let ui_mp = ui.clone();
|
||||
change.connect_clicked(move |_| prompt_mountpoint(&ui_mp));
|
||||
row.add_suffix(&change);
|
||||
}
|
||||
|
||||
/// Sync now (mirror only), the on-demand switch, and Remove — the same three
|
||||
/// controls, and the same handlers, the Computers page used to carry.
|
||||
fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id: i64) {
|
||||
if spec.mode != MountMode::OnDemand {
|
||||
let sync_now = gtk4::Button::builder()
|
||||
.icon_name("view-refresh-symbolic")
|
||||
.tooltip_text("Sync this folder now")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
sync_now.add_css_class("flat");
|
||||
let ui_sync = ui.clone();
|
||||
sync_now.connect_clicked(move |_| sync_folder_now(&ui_sync, id));
|
||||
row.add_suffix(&sync_now);
|
||||
}
|
||||
|
||||
// A queued switch paints as already flipped: the daemon accepted it and will
|
||||
// act on it, so snapping back would read as "it didn't take". The state is
|
||||
// set before the handler is wired so painting the current mode doesn't fire
|
||||
// a spurious request.
|
||||
let target = spec.pending_mode.unwrap_or(spec.mode);
|
||||
let ondemand = gtk4::Switch::builder()
|
||||
.tooltip_text(
|
||||
"On-demand: free this disk by keeping the files in Proton Drive only, \
|
||||
fetching each as you open it. Turn off to download them back and keep a \
|
||||
full local copy.",
|
||||
)
|
||||
.valign(gtk4::Align::Center)
|
||||
.active(target == MountMode::OnDemand)
|
||||
.build();
|
||||
let ui_mode = ui.clone();
|
||||
ondemand.connect_state_set(move |_, on| {
|
||||
set_sync_folder_mode(&ui_mode, id, if on { "ondemand" } else { "mirror" });
|
||||
glib::Propagation::Proceed
|
||||
});
|
||||
row.add_suffix(&ondemand);
|
||||
|
||||
let remove = gtk4::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.tooltip_text("Stop syncing this folder")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
remove.add_css_class("flat");
|
||||
let ui_rm = ui.clone();
|
||||
let path = spec.local_path.clone();
|
||||
// The folder's *current* mode, not a queued one: a switch that hasn't landed
|
||||
// yet has not moved the files anywhere.
|
||||
let is_ondemand = spec.mode == MountMode::OnDemand;
|
||||
remove.connect_clicked(move |_| prompt_remove_sync_folder(&ui_rm, id, &path, is_ondemand));
|
||||
row.add_suffix(&remove);
|
||||
}
|
||||
|
||||
/// Ask the daemon for an immediate pass over one folder.
|
||||
fn sync_folder_now(ui: &Rc<Ui>, id: i64) {
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::SyncNow { id: Some(id) });
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => toast(&ui, "Syncing folder…"),
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't sync", &message, kind)
|
||||
}
|
||||
_ => toast_error(&ui, "Couldn't sync", "The mount service didn't respond."),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn location_icon(kind: &MountKind) -> &'static str {
|
||||
match kind {
|
||||
MountKind::MyFiles => "folder-remote-symbolic",
|
||||
MountKind::Device { .. } => "folder-symbolic",
|
||||
MountKind::Shared { .. } => "system-users-symbolic",
|
||||
}
|
||||
}
|
||||
|
||||
/// One line describing what a location *is* and what it is doing.
|
||||
///
|
||||
/// Ordered by how likely it is to be what the user came to check: a queued mode
|
||||
/// switch leads (they just asked for it), then the resting mode, then the sync
|
||||
/// state, then — only when it is surprising — the fact that no session owns the
|
||||
/// path. A mirror folder is a plain directory with no FUSE session, so "not
|
||||
/// mounted" is its normal state and saying it would be noise.
|
||||
pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
match &spec.kind {
|
||||
MountKind::MyFiles => parts.push("My files".to_string()),
|
||||
MountKind::Shared { .. } => parts.push("Shared folder".to_string()),
|
||||
MountKind::Device { .. } => match (spec.pending_mode, spec.mode) {
|
||||
(Some(MountMode::OnDemand), _) => parts.push("Going on-demand".to_string()),
|
||||
(Some(MountMode::Mirror), _) => parts.push("Switching to synced".to_string()),
|
||||
(Some(MountMode::Unknown) | None, MountMode::OnDemand) => {
|
||||
parts.push("On-demand".to_string())
|
||||
}
|
||||
(Some(MountMode::Unknown) | None, _) => parts.push("Synced".to_string()),
|
||||
},
|
||||
}
|
||||
if matches!(spec.kind, MountKind::Device { .. }) {
|
||||
parts.push(match &spec.progress {
|
||||
Some(p) => sync_progress_label(p),
|
||||
None => sync_state_label(&spec.state).to_string(),
|
||||
});
|
||||
}
|
||||
// Only the locations that are supposed to have a session report its absence:
|
||||
// a mirror folder never has one.
|
||||
let expects_session = !matches!(spec.kind, MountKind::Device { .. })
|
||||
|| spec.mode == MountMode::OnDemand
|
||||
|| spec.pending_mode == Some(MountMode::OnDemand);
|
||||
if expects_session && !spec.mounted {
|
||||
parts.push("not mounted".to_string());
|
||||
}
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn spec(kind: MountKind, mode: MountMode, mounted: bool) -> MountSpec {
|
||||
MountSpec {
|
||||
id: 1,
|
||||
kind,
|
||||
local_path: "/home/u/ProtonDrive".into(),
|
||||
root_uid: "vol~link".into(),
|
||||
root_share_id: "share".into(),
|
||||
mode,
|
||||
access: MountAccess::Rw,
|
||||
state: "idle".into(),
|
||||
last_sync: 0,
|
||||
pending_mode: None,
|
||||
mounted,
|
||||
progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mirror_folder_is_never_reported_as_unmounted() {
|
||||
// It is a plain local directory: no FUSE session is expected, so saying
|
||||
// "not mounted" would describe every healthy mirror folder as broken.
|
||||
let spec = spec(
|
||||
MountKind::Device { sync_folder_id: 7 },
|
||||
MountMode::Mirror,
|
||||
false,
|
||||
);
|
||||
assert_eq!(location_subtitle(&spec), "Synced · up to date");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_on_demand_folder_without_a_session_says_so() {
|
||||
let spec = spec(
|
||||
MountKind::Device { sync_folder_id: 7 },
|
||||
MountMode::OnDemand,
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
location_subtitle(&spec),
|
||||
"On-demand · up to date · not mounted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_queued_switch_leads_the_subtitle() {
|
||||
let mut spec = spec(
|
||||
MountKind::Device { sync_folder_id: 7 },
|
||||
MountMode::Mirror,
|
||||
true,
|
||||
);
|
||||
spec.pending_mode = Some(MountMode::OnDemand);
|
||||
assert!(location_subtitle(&spec).starts_with("Going on-demand"));
|
||||
spec.mode = MountMode::OnDemand;
|
||||
spec.pending_mode = Some(MountMode::Mirror);
|
||||
assert!(location_subtitle(&spec).starts_with("Switching to synced"));
|
||||
}
|
||||
|
||||
/// An unrecognised persisted mode must not silently read as "Synced" *and*
|
||||
/// must not lose the rest of the line.
|
||||
#[test]
|
||||
fn the_primary_mount_reports_only_its_own_facts() {
|
||||
let mut spec = spec(MountKind::MyFiles, MountMode::OnDemand, true);
|
||||
assert_eq!(location_subtitle(&spec), "My files");
|
||||
spec.mounted = false;
|
||||
assert_eq!(location_subtitle(&spec), "My files · not mounted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_state_reaches_the_subtitle() {
|
||||
let mut spec = spec(
|
||||
MountKind::Device { sync_folder_id: 2 },
|
||||
MountMode::Mirror,
|
||||
true,
|
||||
);
|
||||
spec.state = "conflict".into();
|
||||
assert_eq!(location_subtitle(&spec), "Synced · needs attention");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
pub(crate) mod activity;
|
||||
pub(crate) mod albums;
|
||||
pub(crate) mod browser;
|
||||
pub(crate) mod devices;
|
||||
pub(crate) mod locations;
|
||||
pub(crate) mod login;
|
||||
pub(crate) mod photo_viewer;
|
||||
pub(crate) mod photos;
|
||||
|
||||
@@ -19,6 +19,11 @@ pub(crate) struct Viewer {
|
||||
pub(crate) info_map: gtk4::Button,
|
||||
/// Coordinates behind `info_map`, once a photo with GPS tags is shown.
|
||||
pub(crate) coords: RefCell<Option<(f64, f64)>>,
|
||||
/// The favourite toggle in the top bar, and whether it is being set from
|
||||
/// the model rather than by the user — the same suppression the details pane
|
||||
/// uses for its pin switch, so painting a photo doesn't fire a round-trip.
|
||||
pub(crate) favorite: gtk4::ToggleButton,
|
||||
pub(crate) favorite_suppress: Cell<bool>,
|
||||
/// uid of the photo currently on screen.
|
||||
pub(crate) uid: RefCell<String>,
|
||||
/// On-disk path of the full-size photo, once it has been downloaded.
|
||||
@@ -41,6 +46,38 @@ pub(crate) struct ExifInfo {
|
||||
pub(crate) coords: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
/// Paint the favourite toggle without firing its handler.
|
||||
pub(crate) fn show_favorite(viewer: &Rc<Viewer>, favorite: bool) {
|
||||
viewer.favorite_suppress.set(true);
|
||||
viewer.favorite.set_active(favorite);
|
||||
set_favorite_icon(&viewer.favorite, favorite);
|
||||
viewer.favorite_suppress.set(false);
|
||||
}
|
||||
|
||||
/// Filled star for a favourite, outline for the rest.
|
||||
fn set_favorite_icon(button: >k4::ToggleButton, favorite: bool) {
|
||||
button.set_icon_name(if favorite {
|
||||
"starred-symbolic"
|
||||
} else {
|
||||
"non-starred-symbolic"
|
||||
});
|
||||
}
|
||||
|
||||
/// Write a favourite change back into the loaded gallery page, so returning to
|
||||
/// the grid (or reopening the photo) shows what was just set without a reload.
|
||||
fn set_gallery_favorite(ui: &Rc<Ui>, uid: &str, favorite: bool) {
|
||||
let Some(idx) = find_photo_index(&ui.gallery.model, uid) else {
|
||||
return;
|
||||
};
|
||||
let Some(boxed) = ui.gallery.model.item(idx).and_downcast::<BoxedAnyObject>() else {
|
||||
return;
|
||||
};
|
||||
let mut item = boxed.borrow::<PhotoItem>().clone();
|
||||
item.favorite = favorite;
|
||||
ui.gallery.model.remove(idx);
|
||||
ui.gallery.model.insert(idx, &BoxedAnyObject::new(item));
|
||||
}
|
||||
|
||||
/// Show the photo behind `uid`: paint its (already cached) thumbnail immediately
|
||||
/// so the lightbox never opens on a blank screen, ask the daemon for the
|
||||
/// full-size file, and swap it in — plus its EXIF — when it lands.
|
||||
@@ -52,6 +89,14 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
*viewer.path.borrow_mut() = None;
|
||||
clear_info(viewer);
|
||||
|
||||
// The favourite state comes from the timeline page the gallery already has,
|
||||
// so the button is right on the first frame instead of after a round-trip.
|
||||
let favorite = find_photo_index(&ui.gallery.model, &uid)
|
||||
.and_then(|idx| ui.gallery.model.item(idx))
|
||||
.and_downcast::<BoxedAnyObject>()
|
||||
.is_some_and(|boxed| boxed.borrow::<PhotoItem>().favorite);
|
||||
show_favorite(viewer, favorite);
|
||||
|
||||
// The thumbnail the gallery already decoded stands in for the full photo
|
||||
// while it downloads: blurry for a moment beats black for a second.
|
||||
match ui.gallery.photo_tex.borrow().get(&uid) {
|
||||
@@ -450,6 +495,14 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
info_toggle.add_css_class("flat");
|
||||
info_toggle.add_css_class("viewer-action-btn");
|
||||
|
||||
let favorite_btn = gtk4::ToggleButton::builder()
|
||||
.icon_name("non-starred-symbolic")
|
||||
.tooltip_text("Favourite")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
favorite_btn.add_css_class("flat");
|
||||
favorite_btn.add_css_class("viewer-action-btn");
|
||||
|
||||
let download_btn = action("document-save-symbolic", "Save a copy…");
|
||||
let open_ext_btn = action("document-open-symbolic", "Open with another app");
|
||||
let close_btn = action("window-close-symbolic", "Close (Esc)");
|
||||
@@ -460,6 +513,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
top_bar.set_valign(gtk4::Align::Start);
|
||||
top_bar.set_hexpand(true);
|
||||
top_bar.append(&titles);
|
||||
top_bar.append(&favorite_btn);
|
||||
top_bar.append(&info_toggle);
|
||||
top_bar.append(&download_btn);
|
||||
top_bar.append(&open_ext_btn);
|
||||
@@ -558,6 +612,8 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
info_revealer: info_revealer.clone(),
|
||||
info_rows,
|
||||
info_map: info_map.clone(),
|
||||
favorite: favorite_btn.clone(),
|
||||
favorite_suppress: Cell::new(false),
|
||||
coords: RefCell::new(None),
|
||||
uid: RefCell::new(initial_uid.clone()),
|
||||
path: RefCell::new(None),
|
||||
@@ -601,6 +657,44 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
}
|
||||
});
|
||||
|
||||
let ui_fav = ui.clone();
|
||||
let viewer_fav = viewer.clone();
|
||||
favorite_btn.connect_toggled(move |btn| {
|
||||
if viewer_fav.favorite_suppress.get() {
|
||||
return;
|
||||
}
|
||||
let favorite = btn.is_active();
|
||||
set_favorite_icon(btn, favorite);
|
||||
let uid = viewer_fav.uid.borrow().clone();
|
||||
let rx = spawn_request(
|
||||
ui_fav.dirs.control_socket(),
|
||||
Request::SetPhotoFavorite {
|
||||
uid: uid.clone(),
|
||||
favorite,
|
||||
},
|
||||
);
|
||||
let ui_result = ui_fav.clone();
|
||||
let viewer_result = viewer_fav.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let failed = match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
set_gallery_favorite(&ui_result, &uid, favorite);
|
||||
None
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) => Some(message),
|
||||
_ => Some("The mount service didn't respond.".to_string()),
|
||||
};
|
||||
if let Some(detail) = failed {
|
||||
toast_error(&ui_result, "Couldn't change the favourite", &detail);
|
||||
// The server refused, so the button must go back to describing
|
||||
// what is actually stored — without firing this handler again.
|
||||
if *viewer_result.uid.borrow() == uid {
|
||||
show_favorite(&viewer_result, !favorite);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let w_download = window.clone();
|
||||
let viewer_download = viewer.clone();
|
||||
download_btn.connect_clicked(move |_| {
|
||||
|
||||
@@ -8,24 +8,53 @@ pub(crate) struct GalleryState {
|
||||
/// The day sections rendered by the Photos ListView, rebuilt from
|
||||
/// [`Self::model`] by [`repaint_gallery`].
|
||||
pub(crate) groups: gio::ListStore,
|
||||
/// Target row height in px, retuned by Ctrl+scroll / Ctrl+± (see
|
||||
/// [`zoom_gallery`]). Each tile keeps its own aspect ratio; rows are
|
||||
/// justified to the content width around this height.
|
||||
/// Target tile edge in px, retuned by Ctrl+scroll / Ctrl+± (see
|
||||
/// [`zoom_gallery`]). The grid fits as many square tiles of about this size
|
||||
/// as the content width holds.
|
||||
pub(crate) tile: Cell<i32>,
|
||||
/// Swaps the Photos content area between the timeline and its status page.
|
||||
/// Swaps the Photos content area between the timeline, its status page, and
|
||||
/// the Albums grid.
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) more: gtk4::Button,
|
||||
pub(crate) upload: gtk4::Button,
|
||||
/// "Photos", or the album's name while one is open.
|
||||
pub(crate) title: gtk4::Label,
|
||||
/// "1,204 photos" under the page title.
|
||||
pub(crate) subtitle: gtk4::Label,
|
||||
/// The album grid, and the stack that swaps it for its own status page.
|
||||
pub(crate) albums: gtk4::FlowBox,
|
||||
pub(crate) albums_stack: gtk4::Stack,
|
||||
pub(crate) albums_status: adw::StatusPage,
|
||||
/// The Photos/Albums switcher, and the box holding it — hidden while an
|
||||
/// album is open, where the back button leads instead.
|
||||
pub(crate) photos_btn: gtk4::ToggleButton,
|
||||
pub(crate) albums_btn: gtk4::ToggleButton,
|
||||
pub(crate) view_switch: gtk4::Box,
|
||||
/// True while the album listing is in flight, so a re-entry into the Albums
|
||||
/// view can't stack requests.
|
||||
pub(crate) albums_loading: Cell<bool>,
|
||||
/// The album currently open, or `None` when the timeline is showing. Set by
|
||||
/// [`open_album`]; read by [`load_gallery`], which pages that album instead
|
||||
/// of the timeline while it is set.
|
||||
pub(crate) album: RefCell<Option<AlbumInfo>>,
|
||||
/// Leaves an open album for the grid it was opened from. Visible only while
|
||||
/// an album is open.
|
||||
pub(crate) back: gtk4::Button,
|
||||
/// The kind toggles and the date jump, hidden while an album is open — an
|
||||
/// album page is served whole, not filtered.
|
||||
pub(crate) filters: gtk4::Box,
|
||||
/// Which tab (Photos / Videos / Raw) the timeline is filtered to, or `None`
|
||||
/// for All. Read by [`load_gallery`] and set by the filter toggles.
|
||||
pub(crate) kind: Cell<Option<PhotoKind>>,
|
||||
/// The filter toggles, index-aligned with [`kind_for_tab`], kept so their
|
||||
/// labels can carry live per-kind counts.
|
||||
pub(crate) tabs: [gtk4::ToggleButton; 4],
|
||||
/// Whole-timeline `(photos, videos, raw)` counts from the last page that
|
||||
/// carried them, so the subtitle can say how big the library *is* rather
|
||||
/// than how much of it has been paged in.
|
||||
pub(crate) counts: Cell<Option<(usize, usize, usize)>>,
|
||||
/// The date-jump dropdown ("All dates" then a month per timeline entry), and
|
||||
/// the `[from, to)` window each of its rows selects (index-aligned; `None` is
|
||||
/// "All dates"). Selecting a row loads that month via [`load_gallery`].
|
||||
@@ -34,27 +63,24 @@ pub(crate) struct GalleryState {
|
||||
/// The capture-time window the timeline is currently filtered to, or `None`
|
||||
/// for the whole span. Read by [`load_gallery`], set by the date dropdown.
|
||||
pub(crate) range: Cell<Option<(i64, i64)>>,
|
||||
/// The favourites toggle, and whether it is on. When on, the timeline is
|
||||
/// restricted to photos carrying Proton's `Favorite` tag.
|
||||
pub(crate) favorites_btn: gtk4::ToggleButton,
|
||||
pub(crate) favorites: Cell<bool>,
|
||||
/// Set while the date dropdown is being repopulated, so resetting its model
|
||||
/// doesn't fire the selection handler and kick off a spurious reload.
|
||||
pub(crate) date_suppress: Cell<bool>,
|
||||
/// True while a timeline page is in flight, so the scroll-to-the-end paging
|
||||
/// can't fire a second request for the page already coming.
|
||||
pub(crate) loading: Cell<bool>,
|
||||
/// Content width the sections are currently justified to. Updated when the
|
||||
/// ListView is resized, which re-justifies the visible sections.
|
||||
/// Content width the grid is currently laid out to. Updated when the
|
||||
/// ListView is resized, which re-flows the visible sections.
|
||||
pub(crate) width: Cell<i32>,
|
||||
/// Decoded thumbnails by photo uid, with the insertion order that evicts the
|
||||
/// oldest past [`TEXTURE_CACHE_MAX`]. Scrolling back over a day therefore
|
||||
/// repaints from memory instead of re-decoding from disk.
|
||||
pub(crate) photo_tex: RefCell<HashMap<String, gtk4::gdk::Texture>>,
|
||||
pub(crate) photo_tex_order: RefCell<VecDeque<String>>,
|
||||
/// Aspect ratio (w/h) per uid, learned when a thumbnail is decoded and
|
||||
/// persisted to disk, so a relaunch justifies its rows correctly on the first
|
||||
/// frame instead of reflowing as thumbnails arrive.
|
||||
pub(crate) photo_ratio: RefCell<HashMap<String, f64>>,
|
||||
/// Ratios learned since the last save, so the ratio file is only rewritten
|
||||
/// when it actually changed.
|
||||
pub(crate) photo_ratio_dirty: Cell<bool>,
|
||||
/// Photos the daemon reported as having no thumbnail at all, so a tile that
|
||||
/// can never be filled isn't requested again on every scroll past it.
|
||||
pub(crate) photo_nothumb: RefCell<HashSet<String>>,
|
||||
@@ -75,46 +101,33 @@ pub(crate) struct GalleryState {
|
||||
pub(crate) decode_queue: RefCell<VecDeque<(String, String)>>,
|
||||
pub(crate) decode_idle: Cell<bool>,
|
||||
/// Pending debounce timers for the thumbnail queue flush and the section
|
||||
/// re-justify, replaced on each new trigger so only the last one fires.
|
||||
/// re-flow, replaced on each new trigger so only the last one fires.
|
||||
pub(crate) thumb_source: RefCell<Option<glib::SourceId>>,
|
||||
pub(crate) relayout_source: RefCell<Option<glib::SourceId>>,
|
||||
/// The day sections currently realised by the ListView, by their index in
|
||||
/// [`Self::groups`]. A learned ratio or a resize re-justifies these
|
||||
/// in place — rebuilding the ListStore instead would reset the scroll
|
||||
/// position out from under the user.
|
||||
/// [`Self::groups`]. A resize or a zoom step re-flows these in place —
|
||||
/// rebuilding the ListStore instead would reset the scroll position out from
|
||||
/// under the user.
|
||||
pub(crate) bound: RefCell<HashMap<u32, gtk4::Box>>,
|
||||
}
|
||||
|
||||
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
||||
pub(crate) const PHOTOS_PAGE: usize = 60;
|
||||
|
||||
/// Gallery row height in px: the zoom range, its default, and the step one
|
||||
/// Ctrl+scroll notch (or Ctrl+±) moves it by. Rows are justified to the full
|
||||
/// content width, so this is the *target* height a row lands near, not an exact
|
||||
/// tile size (see [`justify_rows`]).
|
||||
/// Gallery tile edge in px: the zoom range, its default, and the step one
|
||||
/// Ctrl+scroll notch (or Ctrl+±) moves it by. The grid divides the content width
|
||||
/// evenly, so this is the *target* a tile lands near rather than its exact size
|
||||
/// (see [`plan_grid`]).
|
||||
pub(crate) const TILE_MIN: i32 = 90;
|
||||
|
||||
pub(crate) const TILE_MAX: i32 = 340;
|
||||
|
||||
pub(crate) const TILE_DEFAULT: i32 = 220;
|
||||
pub(crate) const TILE_DEFAULT: i32 = 180;
|
||||
|
||||
pub(crate) const TILE_STEP: i32 = 30;
|
||||
|
||||
/// Gap between tiles, horizontally and vertically.
|
||||
pub(crate) const TILE_GAP: i32 = 6;
|
||||
|
||||
/// Aspect ratio (w/h) assumed for a photo whose thumbnail hasn't been decoded
|
||||
/// yet, so a tile can be laid out before its image exists. Landscape 3:2, the
|
||||
/// commonest camera/phone ratio; once the thumbnail lands the real ratio
|
||||
/// replaces it and the section re-justifies in place.
|
||||
pub(crate) const RATIO_UNKNOWN: f64 = 1.5;
|
||||
|
||||
/// Aspect ratios are clamped to this range before a row is packed: one absurd
|
||||
/// panorama, or a sliver of a portrait, must not be able to squash the row it
|
||||
/// lands in down to nothing. The tile still shows the whole image.
|
||||
pub(crate) const RATIO_MIN: f64 = 0.4;
|
||||
|
||||
pub(crate) const RATIO_MAX: f64 = 3.5;
|
||||
pub(crate) const TILE_GAP: i32 = 8;
|
||||
|
||||
/// How many thumbnails one on-demand [`Request::PhotoThumbs`] batch asks for.
|
||||
/// Small, so the first tiles on screen fill in quickly rather than the whole
|
||||
@@ -134,7 +147,7 @@ pub(crate) const THUMB_RETRY: Duration = Duration::from_secs(4);
|
||||
/// this caps the gallery's footprint while covering several screens of scroll.
|
||||
pub(crate) const TEXTURE_CACHE_MAX: usize = 600;
|
||||
|
||||
/// Pause after a resize/zoom before the visible sections are re-justified.
|
||||
/// Pause after a resize/zoom before the visible sections are re-flowed.
|
||||
pub(crate) const RELAYOUT_DEBOUNCE: Duration = Duration::from_millis(80);
|
||||
|
||||
/// One day-section of the photos timeline: a heading plus the photos captured
|
||||
@@ -153,9 +166,11 @@ pub(crate) struct GalleryWidgets {
|
||||
pub(crate) model: gio::ListStore,
|
||||
/// Day sections rendered by the ListView, derived from `model`.
|
||||
pub(crate) groups: gio::ListStore,
|
||||
/// Swaps between the timeline and the empty/loading/error status page.
|
||||
/// Swaps between the timeline, the empty/loading/error status page, and the
|
||||
/// Albums grid.
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) title: gtk4::Label,
|
||||
pub(crate) subtitle: gtk4::Label,
|
||||
pub(crate) more: gtk4::Button,
|
||||
pub(crate) list: gtk4::ListView,
|
||||
@@ -163,9 +178,21 @@ pub(crate) struct GalleryWidgets {
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) upload: gtk4::Button,
|
||||
pub(crate) refresh: gtk4::Button,
|
||||
/// The Albums grid, its own status page and the stack between them, plus the
|
||||
/// Photos/Albums switcher and the back button out of an album.
|
||||
pub(crate) albums: gtk4::FlowBox,
|
||||
pub(crate) albums_stack: gtk4::Stack,
|
||||
pub(crate) albums_status: adw::StatusPage,
|
||||
pub(crate) photos_btn: gtk4::ToggleButton,
|
||||
pub(crate) albums_btn: gtk4::ToggleButton,
|
||||
pub(crate) view_switch: gtk4::Box,
|
||||
pub(crate) back: gtk4::Button,
|
||||
/// The kind toggles and date jump, as one box so an album view can hide them.
|
||||
pub(crate) filters: gtk4::Box,
|
||||
/// The All / Photos / Videos / Raw filter toggles, in that order (index maps
|
||||
/// to [`kind_for_tab`]).
|
||||
pub(crate) tabs: [gtk4::ToggleButton; 4],
|
||||
pub(crate) favorites_btn: gtk4::ToggleButton,
|
||||
/// The date-jump dropdown, populated with the timeline's months.
|
||||
pub(crate) dates: gtk4::DropDown,
|
||||
}
|
||||
@@ -217,7 +244,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
more.add_css_class("pill");
|
||||
more.set_visible(false);
|
||||
|
||||
// Horizontal scrolling is never wanted: rows are justified to the viewport
|
||||
// Horizontal scrolling is never wanted: the grid is sized to the viewport
|
||||
// width, and a stray hscrollbar would fight the layout.
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
@@ -226,7 +253,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
.build();
|
||||
|
||||
let title_label = gtk4::Label::builder()
|
||||
.label("Photos")
|
||||
.label("Gallery")
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
title_label.add_css_class("title-2");
|
||||
@@ -243,6 +270,17 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
titles.append(&title_label);
|
||||
titles.append(&subtitle);
|
||||
|
||||
// Leaves an open album for the grid it came from. Only an open album shows
|
||||
// it — the Albums toggle is what leaves the grid itself.
|
||||
let back = gtk4::Button::builder()
|
||||
.icon_name("go-previous-symbolic")
|
||||
.tooltip_text("Back to albums")
|
||||
.valign(gtk4::Align::Center)
|
||||
.visible(false)
|
||||
.build();
|
||||
back.add_css_class("flat");
|
||||
back.add_css_class("circular");
|
||||
|
||||
let upload = gtk4::Button::builder()
|
||||
.label("Upload")
|
||||
.icon_name("list-add-symbolic")
|
||||
@@ -253,6 +291,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
let refresh = refresh_button();
|
||||
|
||||
let header_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
header_box.append(&back);
|
||||
header_box.append(&titles);
|
||||
header_box.append(&refresh);
|
||||
header_box.append(&upload);
|
||||
@@ -280,6 +319,15 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
tab_group.append(btn);
|
||||
}
|
||||
|
||||
// Favourites: a filter, not a tab — it cuts across Photos / Videos / Raw, so
|
||||
// it stays outside the segmented control rather than becoming a fifth option
|
||||
// that would silently drop the kind the user picked.
|
||||
let favorites_btn = gtk4::ToggleButton::builder()
|
||||
.icon_name("starred-symbolic")
|
||||
.tooltip_text("Show only favourites")
|
||||
.build();
|
||||
favorites_btn.add_css_class("pill");
|
||||
|
||||
// Date jump: "All dates" plus a row per month, filled in once the timeline's
|
||||
// months are known (see [`refresh_photo_months`]). Pushed to the far end of
|
||||
// the filter row, opposite the kind toggles.
|
||||
@@ -287,23 +335,74 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
dates.add_css_class("pill");
|
||||
dates.set_tooltip_text(Some("Jump to a month"));
|
||||
|
||||
let filter_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
filter_bar.append(&tab_group);
|
||||
// The kind toggles and the date jump travel together: they filter the
|
||||
// timeline, and neither applies to the album grid or to an open album.
|
||||
let filters = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
filters.set_hexpand(true);
|
||||
filters.append(&tab_group);
|
||||
filters.append(&favorites_btn);
|
||||
let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
spacer.set_hexpand(true);
|
||||
filter_bar.append(&spacer);
|
||||
filter_bar.append(&dates);
|
||||
filters.append(&spacer);
|
||||
filters.append(&dates);
|
||||
|
||||
// The page's two views, as one segmented control under the title: the
|
||||
// timeline, and the albums. This is navigation, not a filter — which is why
|
||||
// it sits above the filter row rather than beside the kind toggles.
|
||||
let photos_btn = gtk4::ToggleButton::builder()
|
||||
.label("Photos")
|
||||
.active(true)
|
||||
.build();
|
||||
let albums_btn = gtk4::ToggleButton::builder().label("Albums").build();
|
||||
albums_btn.set_group(Some(&photos_btn));
|
||||
let view_switch = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
view_switch.add_css_class("linked");
|
||||
view_switch.add_css_class("view-switch");
|
||||
view_switch.set_halign(gtk4::Align::Start);
|
||||
for btn in [&photos_btn, &albums_btn] {
|
||||
btn.add_css_class("pill");
|
||||
view_switch.append(btn);
|
||||
}
|
||||
|
||||
let filter_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
filter_bar.append(&filters);
|
||||
|
||||
// The timeline (plus its pager) or the status page, never both.
|
||||
let timeline = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
timeline.append(&scroll);
|
||||
timeline.append(&more);
|
||||
|
||||
// The album grid: cover-first cards that flow to the width they are given.
|
||||
let albums = gtk4::FlowBox::builder()
|
||||
.selection_mode(gtk4::SelectionMode::None)
|
||||
.homogeneous(true)
|
||||
.row_spacing(TILE_GAP as u32 * 2)
|
||||
.column_spacing(TILE_GAP as u32 * 2)
|
||||
.min_children_per_line(2)
|
||||
.max_children_per_line(8)
|
||||
.valign(gtk4::Align::Start)
|
||||
.build();
|
||||
let albums_scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.child(&albums)
|
||||
.build();
|
||||
let albums_status = adw::StatusPage::builder()
|
||||
.icon_name("view-grid-symbolic")
|
||||
.vexpand(true)
|
||||
.build();
|
||||
albums_status.add_css_class("compact");
|
||||
let albums_stack = gtk4::Stack::new();
|
||||
albums_stack.set_vexpand(true);
|
||||
albums_stack.add_named(&albums_scroll, Some("grid"));
|
||||
albums_stack.add_named(&albums_status, Some("status"));
|
||||
|
||||
let content = gtk4::Stack::new();
|
||||
content.set_vexpand(true);
|
||||
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
||||
content.add_named(&timeline, Some("timeline"));
|
||||
content.add_named(&status, Some("status"));
|
||||
content.add_named(&albums_stack, Some("albums"));
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
inner.set_margin_top(12);
|
||||
@@ -311,6 +410,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&header_box);
|
||||
inner.append(&view_switch);
|
||||
inner.append(&filter_bar);
|
||||
inner.append(&content);
|
||||
|
||||
@@ -321,6 +421,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
groups,
|
||||
content,
|
||||
status,
|
||||
title: title_label,
|
||||
subtitle,
|
||||
more,
|
||||
list,
|
||||
@@ -329,7 +430,16 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
upload,
|
||||
refresh,
|
||||
tabs,
|
||||
favorites_btn,
|
||||
dates,
|
||||
albums,
|
||||
albums_stack,
|
||||
albums_status,
|
||||
photos_btn,
|
||||
albums_btn,
|
||||
view_switch,
|
||||
back,
|
||||
filters,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -416,16 +526,31 @@ pub(crate) fn refresh_photo_months(ui: &Rc<Ui>) {
|
||||
/// behind it is disabled — you can't filter to an empty set — but the currently
|
||||
/// selected one stays clickable so you can always switch back off it.
|
||||
pub(crate) fn update_gallery_tabs(ui: &Rc<Ui>, counts: (usize, usize, usize)) {
|
||||
ui.gallery.counts.set(Some(counts));
|
||||
let (photos, videos, raw) = counts;
|
||||
let totals = [photos + videos + raw, photos, videos, raw];
|
||||
for (index, tab) in ui.gallery.tabs.iter().enumerate() {
|
||||
let name = ["All", "Photos", "Videos", "Raw"][index];
|
||||
let n = totals[index];
|
||||
tab.set_label(&format!("{name} {n}"));
|
||||
tab.set_label(&format!("{name} {}", thousands(n)));
|
||||
tab.set_sensitive(n > 0 || tab.is_active());
|
||||
}
|
||||
}
|
||||
|
||||
/// `1422` as `1,422`. Six-figure libraries are ordinary, and an unseparated run
|
||||
/// of digits in a tab label is unreadable at a glance.
|
||||
pub(crate) fn thousands(n: usize) -> String {
|
||||
let digits = n.to_string();
|
||||
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
|
||||
for (i, c) in digits.chars().enumerate() {
|
||||
if i > 0 && (digits.len() - i).is_multiple_of(3) {
|
||||
out.push(',');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Wire the gallery: install the section factory, the zoom gestures, the pager
|
||||
/// and the upload button. Activating a thumbnail downloads the photo and opens it
|
||||
/// in the in-app lightbox.
|
||||
@@ -485,8 +610,8 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::Sc
|
||||
});
|
||||
list.set_factory(Some(&factory));
|
||||
|
||||
// Rows are justified to the content width, so a resize re-justifies whatever
|
||||
// is on screen (offscreen sections pick the new width up when they bind).
|
||||
// The grid divides the content width, so a resize re-flows whatever is on
|
||||
// screen (offscreen sections pick the new width up when they bind).
|
||||
let ui_width = ui.clone();
|
||||
list.connect_notify_local(Some("width"), move |list, _| {
|
||||
let width = list.width();
|
||||
@@ -570,6 +695,19 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::Sc
|
||||
});
|
||||
}
|
||||
|
||||
// Favourites: reload the timeline restricted to favourites (or back to all).
|
||||
// Independent of the kind tabs and the date jump, both of which keep their
|
||||
// current value across the toggle.
|
||||
let ui_fav = ui.clone();
|
||||
ui.gallery.favorites_btn.connect_toggled(move |btn| {
|
||||
let on = btn.is_active();
|
||||
if ui_fav.gallery.favorites.get() == on {
|
||||
return;
|
||||
}
|
||||
ui_fav.gallery.favorites.set(on);
|
||||
load_gallery(&ui_fav, false);
|
||||
});
|
||||
|
||||
// Date jump: selecting a month loads that window; "All dates" (row 0) clears
|
||||
// it. Skipped while the model is being repopulated (see `gallery_date_suppress`).
|
||||
let ui_dates = ui.clone();
|
||||
@@ -673,114 +811,55 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::Sc
|
||||
});
|
||||
}
|
||||
|
||||
/// One tile in a justified row: the photo, and the pixel size it was justified to.
|
||||
/// One tile of the grid: the photo, and the square edge it was sized to.
|
||||
pub(crate) struct Tile {
|
||||
pub(crate) photo: PhotoItem,
|
||||
pub(crate) width: i32,
|
||||
pub(crate) height: i32,
|
||||
pub(crate) edge: i32,
|
||||
}
|
||||
|
||||
/// Break one day's photos into rows that each fill `width` exactly, at heights
|
||||
/// near [`Ui::gallery_tile`] — the layout every modern photo gallery uses, and
|
||||
/// the reason nothing here has to be cropped: each tile keeps its own aspect
|
||||
/// ratio, and it is the row *height* that flexes to make the widths add up.
|
||||
/// Break one day's photos into rows of equal square tiles that span `width` —
|
||||
/// the layout a phone gallery uses, and the reason a day holding two photos
|
||||
/// looks like every other day rather than like a mistake.
|
||||
///
|
||||
/// Greedy, one pass: keep adding photos to the row: the more photos share it, the
|
||||
/// shorter it has to be to fit. The moment that height drops to the target, the
|
||||
/// row is as full as it should be and is emitted. The trailing row keeps the
|
||||
/// target height instead of being stretched, so a day with three photos doesn't
|
||||
/// blow them up to fill a screen-wide row.
|
||||
///
|
||||
/// Photos whose thumbnail hasn't been decoded yet are laid out at
|
||||
/// [`RATIO_UNKNOWN`]; [`Ui::store_texture`] reports the real ratio when it lands
|
||||
/// and the section is re-justified in place.
|
||||
pub(crate) fn justify_rows(ui: &Rc<Ui>, photos: &[PhotoItem], width: i32) -> Vec<Vec<Tile>> {
|
||||
let ratios: Vec<f64> = photos.iter().map(|photo| ui.ratio(&photo.uid)).collect();
|
||||
let target = f64::from(ui.gallery.tile.get());
|
||||
let plan = plan_rows(&ratios, target, f64::from(width));
|
||||
|
||||
let mut photos = photos.iter();
|
||||
plan.into_iter()
|
||||
/// Each tile is centre-cropped to its square (the full frame is one click away
|
||||
/// in the lightbox), so nothing here depends on knowing a photo's aspect ratio
|
||||
/// and a row never has to be re-flowed when a thumbnail finally lands.
|
||||
pub(crate) fn grid_rows(ui: &Rc<Ui>, photos: &[PhotoItem], width: i32) -> Vec<Vec<Tile>> {
|
||||
let (columns, edge) = plan_grid(ui.gallery.tile.get(), width);
|
||||
photos
|
||||
.chunks(columns)
|
||||
.map(|row| {
|
||||
row.into_iter()
|
||||
.filter_map(|(width, height)| {
|
||||
photos.next().map(|photo| Tile {
|
||||
photo: photo.clone(),
|
||||
width,
|
||||
height,
|
||||
})
|
||||
row.iter()
|
||||
.map(|photo| Tile {
|
||||
photo: photo.clone(),
|
||||
edge,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The row-packing math behind [`justify_rows`], over nothing but aspect ratios:
|
||||
/// takes each photo's ratio (w/h) in order and returns the pixel `(width,
|
||||
/// height)` of every tile, grouped into rows.
|
||||
pub(crate) fn plan_rows(ratios: &[f64], target: f64, width: f64) -> Vec<Vec<(i32, i32)>> {
|
||||
let avail = width.max(f64::from(TILE_MIN));
|
||||
let gap = f64::from(TILE_GAP);
|
||||
|
||||
// The height a row of `n` photos with `sum_ratio` total ratio must take for
|
||||
// its widths (plus gaps) to add up to exactly `avail`.
|
||||
let row_height = |sum_ratio: f64, n: usize| {
|
||||
let gaps = gap * (n.saturating_sub(1)) as f64;
|
||||
(avail - gaps) / sum_ratio
|
||||
};
|
||||
|
||||
let mut rows: Vec<Vec<(i32, i32)>> = Vec::new();
|
||||
let mut row: Vec<f64> = Vec::new();
|
||||
let mut sum_ratio = 0.0;
|
||||
|
||||
for ratio in ratios {
|
||||
let ratio = ratio.clamp(RATIO_MIN, RATIO_MAX);
|
||||
row.push(ratio);
|
||||
sum_ratio += ratio;
|
||||
|
||||
let height = row_height(sum_ratio, row.len());
|
||||
if height <= target {
|
||||
rows.push(size_row(&row, height, avail, true));
|
||||
row.clear();
|
||||
sum_ratio = 0.0;
|
||||
}
|
||||
}
|
||||
if !row.is_empty() {
|
||||
let height = row_height(sum_ratio, row.len()).min(target);
|
||||
rows.push(size_row(&row, height, avail, false));
|
||||
}
|
||||
rows
|
||||
/// The grid math: how many columns fit in `width` at roughly `target` px per
|
||||
/// tile, and the exact square edge that divides the width between them.
|
||||
///
|
||||
/// The column count is what rounds — the edge then absorbs the remainder, so the
|
||||
/// grid spans the full width at every window size instead of leaving a ragged
|
||||
/// margin. Never fewer than one column, however narrow the window gets.
|
||||
pub(crate) fn plan_grid(target: i32, width: i32) -> (usize, i32) {
|
||||
let avail = width.max(TILE_MIN);
|
||||
let target = target.clamp(TILE_MIN, TILE_MAX);
|
||||
// A row of n tiles occupies n*edge + (n-1)*gap, so n tiles of the target size
|
||||
// fit while n*(target + gap) - gap <= avail.
|
||||
let columns = ((avail + TILE_GAP) / (target + TILE_GAP)).max(1) as usize;
|
||||
let gaps = TILE_GAP * (columns as i32 - 1);
|
||||
let edge = ((avail - gaps) / columns as i32).max(1);
|
||||
(columns, edge)
|
||||
}
|
||||
|
||||
/// Size one row's tiles at `height`. A `justified` row is nudged so its widths
|
||||
/// plus gaps hit `avail` exactly — rounding each width independently leaves a
|
||||
/// few px of ragged right edge, so the last tile absorbs the remainder.
|
||||
pub(crate) fn size_row(
|
||||
ratios: &[f64],
|
||||
height: f64,
|
||||
avail: f64,
|
||||
justified: bool,
|
||||
) -> Vec<(i32, i32)> {
|
||||
let height = height.round().max(1.0);
|
||||
let mut sizes: Vec<(i32, i32)> = ratios
|
||||
.iter()
|
||||
.map(|ratio| ((ratio * height).round().max(1.0) as i32, height as i32))
|
||||
.collect();
|
||||
|
||||
if justified {
|
||||
let gaps = TILE_GAP * (sizes.len().saturating_sub(1)) as i32;
|
||||
let used: i32 = sizes.iter().rev().skip(1).map(|(width, _)| *width).sum();
|
||||
if let Some(last) = sizes.last_mut() {
|
||||
last.0 = (avail as i32 - gaps - used).max(1);
|
||||
}
|
||||
}
|
||||
sizes
|
||||
}
|
||||
|
||||
/// (Re)build a bound day-section's tiles: justify this day's photos to the
|
||||
/// (Re)build a bound day-section's tiles: lay this day's photos out to the
|
||||
/// current content width and hand each tile whatever thumbnail is already in
|
||||
/// memory, queueing the rest. Replaces the section's rows in place, leaving the
|
||||
/// heading — so a re-justify never touches the ListView's model or scroll.
|
||||
/// heading — so a re-flow never touches the ListView's model or scroll.
|
||||
pub(crate) fn fill_section(ui: &Rc<Ui>, section: >k4::Box, photos: &[PhotoItem]) {
|
||||
let Some(heading) = section.first_child() else {
|
||||
return;
|
||||
@@ -791,8 +870,11 @@ pub(crate) fn fill_section(ui: &Rc<Ui>, section: >k4::Box, photos: &[PhotoItem
|
||||
|
||||
let width = gallery_width(ui);
|
||||
let rows = gtk4::Box::new(gtk4::Orientation::Vertical, TILE_GAP);
|
||||
for row in justify_rows(ui, photos, width) {
|
||||
for row in grid_rows(ui, photos, width) {
|
||||
let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, TILE_GAP);
|
||||
// A short last row stays left-aligned at the same tile size rather than
|
||||
// stretching: an even grid is the whole point of the square layout.
|
||||
row_box.set_halign(gtk4::Align::Start);
|
||||
for tile in row {
|
||||
row_box.append(&photo_tile(ui, tile));
|
||||
}
|
||||
@@ -802,8 +884,8 @@ pub(crate) fn fill_section(ui: &Rc<Ui>, section: >k4::Box, photos: &[PhotoItem
|
||||
schedule_thumbs(ui);
|
||||
}
|
||||
|
||||
/// The width justified rows are laid out to: the ListView's own width, less a
|
||||
/// couple of px so a rounding error can't push a row into a horizontal overflow.
|
||||
/// The width the grid is laid out to: the ListView's own width, less a couple of
|
||||
/// px so a rounding error can't push a row into a horizontal overflow.
|
||||
/// Falls back to a sane guess before the first allocation.
|
||||
pub(crate) fn gallery_width(ui: &Rc<Ui>) -> i32 {
|
||||
match ui.gallery.width.get() {
|
||||
@@ -822,12 +904,17 @@ pub(crate) fn gallery_width(ui: &Rc<Ui>) -> i32 {
|
||||
/// one keeps an image glyph instead of an empty rectangle.
|
||||
pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
|
||||
let picture = gtk4::Picture::builder()
|
||||
// The tile is exactly the thumbnail's own aspect ratio, so Cover scales
|
||||
// it and crops nothing; it only bites during the brief window where the
|
||||
// ratio is still a guess, and the re-justify then fixes the tile.
|
||||
// Cover fills the square and crops the overflow, so a portrait photo
|
||||
// sits flush in its tile instead of floating in letterbox bars. The
|
||||
// expands are what make the picture take the whole overlay: without
|
||||
// them it is allocated its natural size and the crop never happens.
|
||||
.content_fit(gtk4::ContentFit::Cover)
|
||||
.can_shrink(true)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
// Zoomed on hover by the stylesheet; the tile clips the overflow.
|
||||
picture.add_css_class("photo-thumb");
|
||||
|
||||
let placeholder = gtk4::Image::builder()
|
||||
.icon_name("image-x-generic-symbolic")
|
||||
@@ -871,8 +958,8 @@ pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
|
||||
|
||||
let button = gtk4::Button::builder()
|
||||
.child(&overlay)
|
||||
.width_request(tile.width)
|
||||
.height_request(tile.height)
|
||||
.width_request(tile.edge)
|
||||
.height_request(tile.edge)
|
||||
.tooltip_text(format_capture_time(tile.photo.capture_time))
|
||||
.build();
|
||||
button.add_css_class("photo-tile");
|
||||
@@ -1052,7 +1139,6 @@ pub(crate) fn schedule_decode(ui: &Rc<Ui>) {
|
||||
let mut queue = ui.gallery.decode_queue.borrow_mut();
|
||||
(0..4).filter_map(|_| queue.pop_front()).collect()
|
||||
};
|
||||
let mut relayout = false;
|
||||
for (uid, path) in batch {
|
||||
let texture = match gtk4::gdk::Texture::from_filename(&path) {
|
||||
Ok(texture) => texture,
|
||||
@@ -1062,28 +1148,23 @@ pub(crate) fn schedule_decode(ui: &Rc<Ui>) {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// A ratio we hadn't seen means the tile was sized against a guess.
|
||||
relayout |= ui.store_texture(&uid, texture.clone());
|
||||
ui.store_texture(&uid, texture.clone());
|
||||
if let Some(picture) = ui.gallery.thumb_wanted.borrow_mut().remove(&uid) {
|
||||
picture.set_paintable(Some(&texture));
|
||||
}
|
||||
}
|
||||
if relayout {
|
||||
schedule_relayout(&ui);
|
||||
}
|
||||
|
||||
if ui.gallery.decode_queue.borrow().is_empty() {
|
||||
ui.gallery.decode_idle.set(false);
|
||||
ui.save_ratios();
|
||||
return glib::ControlFlow::Break;
|
||||
}
|
||||
glib::ControlFlow::Continue
|
||||
});
|
||||
}
|
||||
|
||||
/// Re-justify the sections on screen shortly. Debounced, because the triggers
|
||||
/// (a window resize, a zoom step, a burst of decoded thumbnails) all arrive in
|
||||
/// floods and only the final state matters.
|
||||
/// Re-flow the sections on screen shortly. Debounced, because the triggers (a
|
||||
/// window resize, a zoom step) arrive in floods and only the final state
|
||||
/// matters.
|
||||
pub(crate) fn schedule_relayout(ui: &Rc<Ui>) {
|
||||
if let Some(id) = ui.gallery.relayout_source.borrow_mut().take() {
|
||||
id.remove();
|
||||
@@ -1097,9 +1178,8 @@ pub(crate) fn schedule_relayout(ui: &Rc<Ui>) {
|
||||
}
|
||||
|
||||
/// Rebuild the tiles of the day sections currently on screen, at the current
|
||||
/// width, zoom and set of known aspect ratios. Sections that are *not* realised
|
||||
/// need no work: they justify themselves against the current state when the
|
||||
/// ListView binds them.
|
||||
/// width and zoom. Sections that are *not* realised need no work: they lay
|
||||
/// themselves out against the current state when the ListView binds them.
|
||||
pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
|
||||
let bound: Vec<(u32, gtk4::Box)> = ui
|
||||
.gallery
|
||||
@@ -1120,12 +1200,12 @@ pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Step the row height by `delta` px and re-justify, clamped to the zoom range.
|
||||
/// Step the tile size by `delta` px and re-flow, clamped to the zoom range.
|
||||
pub(crate) fn zoom_gallery(ui: &Rc<Ui>, delta: i32) {
|
||||
set_gallery_tile(ui, ui.gallery.tile.get() + delta);
|
||||
}
|
||||
|
||||
/// Set the row height (clamped) and re-justify the visible sections at it.
|
||||
/// Set the tile size (clamped) and re-flow the visible sections at it.
|
||||
pub(crate) fn set_gallery_tile(ui: &Rc<Ui>, tile: i32) {
|
||||
let tile = tile.clamp(TILE_MIN, TILE_MAX);
|
||||
if tile == ui.gallery.tile.get() {
|
||||
@@ -1175,17 +1255,35 @@ pub(crate) fn repaint_gallery(ui: &Rc<Ui>) {
|
||||
store.splice(len, store.n_items() - len, &[] as &[BoxedAnyObject]);
|
||||
}
|
||||
|
||||
let count = ui.gallery.model.n_items();
|
||||
ui.gallery.subtitle.set_visible(count > 0);
|
||||
let loaded = ui.gallery.model.n_items() as usize;
|
||||
ui.gallery.subtitle.set_visible(loaded > 0);
|
||||
// An album counts what the server says it holds, not how much of it has been
|
||||
// paged in — the subtitle would otherwise climb as the user scrolls.
|
||||
if ui.gallery.album.borrow().is_some() {
|
||||
return;
|
||||
}
|
||||
// The noun tracks the active filter, so a Videos tab doesn't count "photos".
|
||||
let (one, many) = match ui.gallery.kind.get() {
|
||||
let kind = ui.gallery.kind.get();
|
||||
let (one, many) = match kind {
|
||||
Some(PhotoKind::Video) => ("video", "videos"),
|
||||
Some(PhotoKind::Raw) => ("raw photo", "raw photos"),
|
||||
_ => ("photo", "photos"),
|
||||
};
|
||||
ui.gallery.subtitle.set_label(&match count {
|
||||
// The whole library for this filter, not the page count — the subtitle sits
|
||||
// next to tabs carrying the same totals, and the two disagreeing reads as a
|
||||
// bug. A date jump is the exception: there the window is the subject.
|
||||
let total = match (ui.gallery.range.get(), ui.gallery.counts.get()) {
|
||||
(None, Some((photos, videos, raw))) => match kind {
|
||||
Some(PhotoKind::Photo) => photos,
|
||||
Some(PhotoKind::Video) => videos,
|
||||
Some(PhotoKind::Raw) => raw,
|
||||
None => photos + videos + raw,
|
||||
},
|
||||
_ => loaded,
|
||||
};
|
||||
ui.gallery.subtitle.set_label(&match total {
|
||||
1 => format!("1 {one}"),
|
||||
n => format!("{n} {many}"),
|
||||
n => format!("{} {many}", thousands(n)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1272,6 +1370,9 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
if ui.gallery.loading.get() {
|
||||
return;
|
||||
}
|
||||
// An open album pages itself instead of the timeline; everything downstream —
|
||||
// the model, the sections, the thumbnails, the lightbox — is the same.
|
||||
let album = ui.gallery.album.borrow().as_ref().map(|a| a.uid.clone());
|
||||
if !append {
|
||||
// Fresh load: clear the timeline and show Loading until the first page lands.
|
||||
ui.gallery.model.remove_all();
|
||||
@@ -1284,8 +1385,9 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
);
|
||||
// Rebuild the date jump for the current kind, but only for a full-span
|
||||
// load — a jump *to* a month sets a range and reloads, and refreshing the
|
||||
// dropdown then would fight the selection the user just made.
|
||||
if ui.gallery.range.get().is_none() {
|
||||
// dropdown then would fight the selection the user just made. An album
|
||||
// has no date jump at all.
|
||||
if album.is_none() && ui.gallery.range.get().is_none() {
|
||||
refresh_photo_months(ui);
|
||||
}
|
||||
}
|
||||
@@ -1294,15 +1396,21 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
ui.gallery.more.set_sensitive(false);
|
||||
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::PhotosTimeline {
|
||||
let request = match album {
|
||||
Some(uid) => Request::AlbumPhotos {
|
||||
uid,
|
||||
offset,
|
||||
limit: PHOTOS_PAGE,
|
||||
},
|
||||
None => Request::PhotosTimeline {
|
||||
offset,
|
||||
limit: PHOTOS_PAGE,
|
||||
kind: ui.gallery.kind.get(),
|
||||
range: ui.gallery.range.get(),
|
||||
favorites: ui.gallery.favorites.get(),
|
||||
},
|
||||
);
|
||||
};
|
||||
let rx = spawn_request(ui.dirs.control_socket(), request);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
@@ -1329,20 +1437,13 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
if let Some(counts) = counts {
|
||||
update_gallery_tabs(&ui, counts);
|
||||
}
|
||||
// Take the daemon's word on ratios and thumbnail verdicts before
|
||||
// anything is laid out: it persists both, so the very first frame
|
||||
// justifies its rows correctly instead of guessing RATIO_UNKNOWN
|
||||
// and reflowing as the images land.
|
||||
// Take the daemon's word on which photos can never have a
|
||||
// thumbnail, so their tiles show a placeholder from the first
|
||||
// frame instead of queueing a request that can only fail.
|
||||
{
|
||||
let mut ratios = ui.gallery.photo_ratio.borrow_mut();
|
||||
let mut nothumb = ui.gallery.photo_nothumb.borrow_mut();
|
||||
for item in &items {
|
||||
if let Some(ratio) = item.ratio {
|
||||
ratios.insert(item.uid.clone(), ratio);
|
||||
}
|
||||
if item.no_thumb {
|
||||
nothumb.insert(item.uid.clone());
|
||||
}
|
||||
for item in items.iter().filter(|item| item.no_thumb) {
|
||||
nothumb.insert(item.uid.clone());
|
||||
}
|
||||
}
|
||||
for item in &items {
|
||||
@@ -1350,13 +1451,15 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
}
|
||||
repaint_gallery(&ui);
|
||||
if ui.gallery.model.n_items() == 0 {
|
||||
gallery_status(
|
||||
&ui,
|
||||
"image-x-generic-symbolic",
|
||||
"No photos yet",
|
||||
"Photos you upload to Proton Drive appear here.",
|
||||
false,
|
||||
);
|
||||
let (title, description) = if ui.gallery.album.borrow().is_some() {
|
||||
("Empty album", "This album has no photos in it.")
|
||||
} else {
|
||||
(
|
||||
"No photos yet",
|
||||
"Photos you upload to Proton Drive appear here.",
|
||||
)
|
||||
};
|
||||
gallery_status(&ui, "image-x-generic-symbolic", title, description, false);
|
||||
return;
|
||||
}
|
||||
ui.gallery.content.set_visible_child_name("timeline");
|
||||
|
||||
@@ -298,8 +298,15 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
|
||||
.title(&entry.name)
|
||||
.activatable(true)
|
||||
.build();
|
||||
if !entry.is_dir {
|
||||
row.set_subtitle(&human_bytes(entry.size));
|
||||
let subtitle = shared_row_subtitle(entry);
|
||||
if !subtitle.is_empty() {
|
||||
row.set_subtitle(&subtitle);
|
||||
}
|
||||
// What I am allowed to do with someone else's file is the thing this page
|
||||
// cannot leave implicit: a viewer share is browsable and unwritable, and
|
||||
// that is only visible if it is said.
|
||||
if let Some(badge) = role_badge(&entry.role) {
|
||||
row.add_suffix(&badge);
|
||||
}
|
||||
row.add_prefix(>k4::Image::from_icon_name(if entry.is_dir {
|
||||
"folder-symbolic"
|
||||
@@ -325,6 +332,43 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
|
||||
row
|
||||
}
|
||||
|
||||
/// A shared row's subtitle: its size (files only), and where it can be reached
|
||||
/// in the local mount once the synthetic `Shared with me/` directory has interned
|
||||
/// it. An unlisted share has no local path yet, and says nothing rather than
|
||||
/// pointing at a directory that is not there.
|
||||
pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if !entry.is_dir {
|
||||
parts.push(human_bytes(entry.size));
|
||||
}
|
||||
if !entry.path.is_empty() {
|
||||
parts.push(format!("in {}", entry.path));
|
||||
}
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
/// The display name for a wire role, or `None` when there is nothing to show:
|
||||
/// owned content carries no role, and a role the API did not report is not
|
||||
/// guessed at (the SDK's `from_permissions_exact` returns `None` for an
|
||||
/// unrecognised mask rather than degrading it to viewer).
|
||||
pub(crate) fn role_label(role: &str) -> Option<&'static str> {
|
||||
match role {
|
||||
"viewer" => Some("Viewer"),
|
||||
"editor" => Some("Editor"),
|
||||
"admin" => Some("Admin"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A dim role pill for a share I did not create.
|
||||
pub(crate) fn role_badge(role: &str) -> Option<gtk4::Label> {
|
||||
let badge = gtk4::Label::new(Some(role_label(role)?));
|
||||
badge.add_css_class("dim-label");
|
||||
badge.add_css_class("caption");
|
||||
badge.set_valign(gtk4::Align::Center);
|
||||
Some(badge)
|
||||
}
|
||||
|
||||
/// Download a file shared with me into the daemon's cache and hand it to the
|
||||
/// user's default application — the shared-item twin of the browser's
|
||||
/// download-and-open, addressed by uid because the file lives outside the mount.
|
||||
@@ -663,3 +707,52 @@ pub(crate) fn run_shared_mutation(
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(is_dir: bool, path: &str, role: &str) -> DirEntry {
|
||||
DirEntry {
|
||||
name: "Budget.ods".into(),
|
||||
is_dir,
|
||||
size: 2048,
|
||||
modified: 0,
|
||||
pinned: false,
|
||||
cached: false,
|
||||
uid: "vol~link".into(),
|
||||
path: path.into(),
|
||||
role: role.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_share_with_no_local_path_says_nothing_about_one() {
|
||||
// Before the synthetic directory has been listed, the daemon cannot
|
||||
// resolve a path — pointing at one that isn't there would be worse than
|
||||
// silence.
|
||||
assert_eq!(shared_row_subtitle(&entry(true, "", "viewer")), "");
|
||||
assert_eq!(
|
||||
shared_row_subtitle(&entry(false, "", "viewer")),
|
||||
human_bytes(2048)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resident_share_reports_where_it_can_be_reached() {
|
||||
let subtitle = shared_row_subtitle(&entry(true, "Shared with me/Team Budget", "editor"));
|
||||
assert_eq!(subtitle, "in Shared with me/Team Budget");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_known_role_becomes_a_badge() {
|
||||
// "" is owned content (no role applies) and an unrecognised mask is
|
||||
// deliberately not guessed at — `from_permissions_exact` returns None
|
||||
// rather than degrading to viewer, and this must not undo that.
|
||||
for role in ["viewer", "editor", "admin"] {
|
||||
assert!(role_label(role).is_some(), "{role} should have a label");
|
||||
}
|
||||
assert!(role_label("").is_none());
|
||||
assert!(role_label("inherited").is_none());
|
||||
}
|
||||
}
|
||||
@@ -236,19 +236,18 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
||||
row.add_suffix(&open);
|
||||
}
|
||||
|
||||
// Manage opens the per-node Share dialog — only when the node's path is
|
||||
// known (it has been browsed to this session); the dialog is path-keyed.
|
||||
if !item.path.is_empty() {
|
||||
let manage = gtk4::Button::builder()
|
||||
.label("Manage")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
manage.add_css_class("flat");
|
||||
let ui_manage = ui.clone();
|
||||
let entry = shared_item_as_entry(item);
|
||||
manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry));
|
||||
row.add_suffix(&manage);
|
||||
}
|
||||
// Manage opens the per-node Share dialog. It used to be offered only for
|
||||
// an item whose path the daemon could resolve; the dialog now addresses a
|
||||
// pathless node by uid, so every shared item can be managed from here.
|
||||
let manage = gtk4::Button::builder()
|
||||
.label("Manage")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
manage.add_css_class("flat");
|
||||
let ui_manage = ui.clone();
|
||||
let entry = shared_item_as_entry(item);
|
||||
manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry));
|
||||
row.add_suffix(&manage);
|
||||
|
||||
ui.shared_by_me.group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
@@ -295,5 +294,7 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
||||
cached: false,
|
||||
uid: item.uid.clone(),
|
||||
path: item.path.clone(),
|
||||
// Shared *by* me: I own it, so there is no role of mine to report.
|
||||
role: String::new(),
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,8 @@ pub(crate) struct StatusState {
|
||||
/// Cache-budget editor (GiB). Populated once from config; user edits drive a
|
||||
/// `SetCacheBudget` round-trip. Guarded by [`Self::settings_suppress`].
|
||||
pub(crate) budget_row: adw::SpinRow,
|
||||
/// Shows the active mountpoint in its subtitle; updated when the user picks a
|
||||
/// new folder.
|
||||
/// Shows where the primary mount lives; the folder itself is managed on the
|
||||
/// Locations page, which owns every local path.
|
||||
pub(crate) mountpoint_row: adw::ActionRow,
|
||||
/// Set while a settings widget is being populated programmatically, so its
|
||||
/// change handler skips the IPC/systemd side effect.
|
||||
@@ -100,7 +100,8 @@ pub(crate) struct MainWidgets {
|
||||
pub(crate) budget_row: adw::SpinRow,
|
||||
/// Purges all unpinned cached content.
|
||||
pub(crate) purge_button: gtk4::Button,
|
||||
/// Shows the active mountpoint; its suffix button opens a folder chooser.
|
||||
/// Shows the active mountpoint; its suffix button opens the Locations page,
|
||||
/// which is where the folder is changed.
|
||||
pub(crate) mountpoint_row: adw::ActionRow,
|
||||
pub(crate) mountpoint_button: gtk4::Button,
|
||||
}
|
||||
@@ -215,12 +216,17 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
|
||||
.subtitle("Mount Proton Drive automatically when you log in.")
|
||||
.build();
|
||||
system_group.add(&autostart_row);
|
||||
// The mountpoint is a *location*, and every other local path this client
|
||||
// owns is managed on the Locations page. Keeping a second chooser here would
|
||||
// be a second source of truth for the same setting, so this row reports the
|
||||
// path and hands the change over.
|
||||
let mountpoint_row = adw::ActionRow::builder()
|
||||
.title("Mountpoint")
|
||||
.subtitle("—")
|
||||
.build();
|
||||
let mountpoint_button = gtk4::Button::builder()
|
||||
.label("Change")
|
||||
.label("Locations")
|
||||
.tooltip_text("Manage this computer's Proton Drive locations")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
mountpoint_button.add_css_class("flat");
|
||||
@@ -372,10 +378,10 @@ pub(crate) fn wire_settings(
|
||||
}
|
||||
});
|
||||
|
||||
// Mountpoint: pick a folder, persist it, and offer to restart the mount so
|
||||
// the change takes effect (the daemon reads the path on mount).
|
||||
// Mountpoint: the chooser lives on the Locations page, next to every other
|
||||
// local path; this button is the way there.
|
||||
let ui_mp = ui.clone();
|
||||
mountpoint_button.connect_clicked(move |_| prompt_mountpoint(&ui_mp));
|
||||
mountpoint_button.connect_clicked(move |_| ui_mp.stack.set_visible_child_name("locations"));
|
||||
}
|
||||
|
||||
/// Run a settings control-socket round-trip (budget / purge) on a worker thread,
|
||||
@@ -423,6 +429,12 @@ pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
|
||||
return;
|
||||
}
|
||||
ui.status.mountpoint_row.set_subtitle(&path_str);
|
||||
// The Locations page shows the same path as a row title; its cached rows
|
||||
// are now stale whether or not it is the visible page.
|
||||
ui.locations.loaded_at.set(None);
|
||||
if ui.stack.visible_child_name().as_deref() == Some("locations") {
|
||||
load_locations(&ui);
|
||||
}
|
||||
|
||||
// The daemon only reads the mountpoint at mount time, so offer a restart.
|
||||
let confirm = adw::AlertDialog::builder()
|
||||
@@ -503,7 +515,7 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
|
||||
// they are on screen. Every other page loads on navigation only.
|
||||
match ui.stack.visible_child_name().as_deref() {
|
||||
Some("main") => refresh_quota(ui),
|
||||
Some("devices") => refresh_sync_folders(ui),
|
||||
Some("locations") => refresh_locations(ui),
|
||||
Some("activity") => refresh_activity(ui),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ pub(crate) struct DetailsWidgets {
|
||||
pub(crate) pin_row: adw::SwitchRow,
|
||||
pub(crate) open_button: gtk4::Button,
|
||||
pub(crate) rename_button: gtk4::Button,
|
||||
/// Files only — folders have no revision history.
|
||||
pub(crate) versions_button: gtk4::Button,
|
||||
pub(crate) trash_button: gtk4::Button,
|
||||
pub(crate) close_button: gtk4::Button,
|
||||
}
|
||||
@@ -96,6 +98,8 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
open_button.add_css_class("pill");
|
||||
let rename_button = gtk4::Button::builder().label("Rename").build();
|
||||
rename_button.add_css_class("pill");
|
||||
let versions_button = gtk4::Button::builder().label("Versions").build();
|
||||
versions_button.add_css_class("pill");
|
||||
let trash_button = gtk4::Button::builder().label("Move to Trash").build();
|
||||
trash_button.add_css_class("destructive-action");
|
||||
trash_button.add_css_class("pill");
|
||||
@@ -104,6 +108,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
actions.set_margin_top(6);
|
||||
actions.append(&open_button);
|
||||
actions.append(&rename_button);
|
||||
actions.append(&versions_button);
|
||||
actions.append(&trash_button);
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
@@ -137,6 +142,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
pin_row,
|
||||
open_button,
|
||||
rename_button,
|
||||
versions_button,
|
||||
trash_button,
|
||||
close_button,
|
||||
},
|
||||
@@ -190,6 +196,16 @@ pub(crate) fn wire_details(ui: &Rc<Ui>) {
|
||||
prompt_rename(&ui_rename, &entry);
|
||||
}
|
||||
});
|
||||
let ui_versions = ui.clone();
|
||||
ui.details
|
||||
.details
|
||||
.versions_button
|
||||
.connect_clicked(move |_| {
|
||||
let entry = ui_versions.details.details_entry.borrow().clone();
|
||||
if let Some(entry) = entry {
|
||||
open_versions_dialog(&ui_versions, &entry);
|
||||
}
|
||||
});
|
||||
let ui_trash = ui.clone();
|
||||
ui.details.details.trash_button.connect_clicked(move |_| {
|
||||
let entry = ui_trash.details.details_entry.borrow().clone();
|
||||
@@ -246,6 +262,9 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
d.pin_row.set_sensitive(*ui.mounted.borrow());
|
||||
d.open_button
|
||||
.set_label(if entry.is_dir { "Open folder" } else { "Open" });
|
||||
// Only files have revisions, and only a connected daemon can fetch them.
|
||||
d.versions_button.set_visible(!entry.is_dir);
|
||||
d.versions_button.set_sensitive(*ui.mounted.borrow());
|
||||
ui.details.details_suppress.set(false);
|
||||
|
||||
*ui.details.details_entry.borrow_mut() = Some(entry.clone());
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub(crate) mod details;
|
||||
pub(crate) mod share_dialog;
|
||||
pub(crate) mod versions_dialog;
|
||||
@@ -21,13 +21,117 @@ pub(crate) fn role_wire_to_index(role: &str) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// How an open Share dialog addresses its node.
|
||||
///
|
||||
/// A node the browser is showing has a mountpoint-relative path. A node reached
|
||||
/// from Shared or Shared-with-me may not: it can live under a device folder's
|
||||
/// own inode space, or on someone else's volume, neither of which the primary
|
||||
/// mount's path space can name. Those carry a uid instead, and the daemon
|
||||
/// resolves it with `Core::resolve_anywhere` (mount-architecture.md P4).
|
||||
///
|
||||
/// The two are separate request variants rather than one request with an
|
||||
/// optional field on purpose: an older daemon that ignored `uid` would resolve
|
||||
/// the empty path to the *mount root* and act on the wrong node.
|
||||
pub(crate) enum ShareTarget {
|
||||
Path(String),
|
||||
Uid(String),
|
||||
}
|
||||
|
||||
impl ShareTarget {
|
||||
fn list(&self) -> Request {
|
||||
match self {
|
||||
ShareTarget::Path(path) => Request::ListShare { path: path.clone() },
|
||||
ShareTarget::Uid(uid) => Request::ListShareByUid { uid: uid.clone() },
|
||||
}
|
||||
}
|
||||
|
||||
fn invite(&self, emails: Vec<String>, role: String, message: Option<String>) -> Request {
|
||||
match self {
|
||||
ShareTarget::Path(path) => Request::ShareNode {
|
||||
path: path.clone(),
|
||||
emails,
|
||||
role,
|
||||
message,
|
||||
},
|
||||
ShareTarget::Uid(uid) => Request::ShareNodeByUid {
|
||||
uid: uid.clone(),
|
||||
emails,
|
||||
role,
|
||||
message,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn update_role(&self, id: String, kind: ShareEntryKind, role: String) -> Request {
|
||||
match self {
|
||||
ShareTarget::Path(path) => Request::UpdateShareRole {
|
||||
path: path.clone(),
|
||||
id,
|
||||
kind,
|
||||
role,
|
||||
},
|
||||
ShareTarget::Uid(uid) => Request::UpdateShareRoleByUid {
|
||||
uid: uid.clone(),
|
||||
id,
|
||||
kind,
|
||||
role,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_entry(&self, id: String, kind: ShareEntryKind) -> Request {
|
||||
match self {
|
||||
ShareTarget::Path(path) => Request::RemoveShareEntry {
|
||||
path: path.clone(),
|
||||
id,
|
||||
kind,
|
||||
},
|
||||
ShareTarget::Uid(uid) => Request::RemoveShareEntryByUid {
|
||||
uid: uid.clone(),
|
||||
id,
|
||||
kind,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn create_link(&self, role: String, password: Option<String>) -> Request {
|
||||
match self {
|
||||
ShareTarget::Path(path) => Request::CreatePublicLink {
|
||||
path: path.clone(),
|
||||
role,
|
||||
password,
|
||||
expires: None,
|
||||
},
|
||||
ShareTarget::Uid(uid) => Request::CreatePublicLinkByUid {
|
||||
uid: uid.clone(),
|
||||
role,
|
||||
password,
|
||||
expires: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_link(&self, id: String) -> Request {
|
||||
match self {
|
||||
ShareTarget::Path(path) => Request::RemovePublicLink {
|
||||
path: path.clone(),
|
||||
id,
|
||||
},
|
||||
ShareTarget::Uid(uid) => Request::RemovePublicLinkByUid {
|
||||
uid: uid.clone(),
|
||||
id,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mutable state behind an open Share dialog, so the invite/role/link
|
||||
/// handlers can rebuild the people and link sections after each change without
|
||||
/// tearing the whole dialog down.
|
||||
pub(crate) struct ShareDialog {
|
||||
pub(crate) ui: Rc<Ui>,
|
||||
/// The node's mountpoint-relative path — how every request addresses it.
|
||||
pub(crate) rel: String,
|
||||
/// How every request from this dialog addresses the node.
|
||||
pub(crate) target: ShareTarget,
|
||||
pub(crate) people: adw::PreferencesGroup,
|
||||
pub(crate) link_group: adw::PreferencesGroup,
|
||||
pub(crate) people_rows: RefCell<Vec<gtk4::Widget>>,
|
||||
@@ -41,7 +145,13 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
toast_error(ui, "Can't share", "Proton Drive isn't connected.");
|
||||
return;
|
||||
}
|
||||
let rel = entry_rel(ui, entry);
|
||||
// A node with no path is not a bug here: Shared and Shared-with-me list
|
||||
// nodes that the primary mount never interned. Address those by uid.
|
||||
let target = if entry.path.is_empty() {
|
||||
ShareTarget::Uid(entry.uid.clone())
|
||||
} else {
|
||||
ShareTarget::Path(entry_rel(ui, entry))
|
||||
};
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
let header = adw::HeaderBar::new();
|
||||
@@ -110,7 +220,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
|
||||
let state = Rc::new(ShareDialog {
|
||||
ui: ui.clone(),
|
||||
rel,
|
||||
target,
|
||||
people,
|
||||
link_group,
|
||||
people_rows: RefCell::new(Vec::new()),
|
||||
@@ -144,12 +254,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let msg_clear = message_row.clone();
|
||||
share_dialog_op(
|
||||
&state_inv,
|
||||
Request::ShareNode {
|
||||
path: state_inv.rel.clone(),
|
||||
emails,
|
||||
role,
|
||||
message,
|
||||
},
|
||||
state_inv.target.invite(emails, role, message),
|
||||
"Invitations sent",
|
||||
"Couldn't send invitations",
|
||||
Some(Box::new(move || {
|
||||
@@ -165,12 +270,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
|
||||
/// Re-fetch the node's share and rebuild the people + public-link sections.
|
||||
pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
|
||||
let rx = spawn_request(
|
||||
state.ui.dirs.control_socket(),
|
||||
Request::ListShare {
|
||||
path: state.rel.clone(),
|
||||
},
|
||||
);
|
||||
let rx = spawn_request(state.ui.dirs.control_socket(), state.target.list());
|
||||
let state = state.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
@@ -232,12 +332,11 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
||||
drop.connect_selected_notify(move |d| {
|
||||
share_dialog_op(
|
||||
&state_role,
|
||||
Request::UpdateShareRole {
|
||||
path: state_role.rel.clone(),
|
||||
id: id.clone(),
|
||||
state_role.target.update_role(
|
||||
id.clone(),
|
||||
kind,
|
||||
role: role_index_to_wire(d.selected()).to_string(),
|
||||
},
|
||||
role_index_to_wire(d.selected()).to_string(),
|
||||
),
|
||||
"Role updated",
|
||||
"Couldn't update the role",
|
||||
None,
|
||||
@@ -265,11 +364,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
||||
remove.connect_clicked(move |_| {
|
||||
share_dialog_op(
|
||||
&state_rm,
|
||||
Request::RemoveShareEntry {
|
||||
path: state_rm.rel.clone(),
|
||||
id: id.clone(),
|
||||
kind,
|
||||
},
|
||||
state_rm.target.remove_entry(id.clone(), kind),
|
||||
"Access removed",
|
||||
"Couldn't remove access",
|
||||
None,
|
||||
@@ -332,10 +427,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
remove.connect_clicked(move |_| {
|
||||
share_dialog_op(
|
||||
&state_rm,
|
||||
Request::RemovePublicLink {
|
||||
path: state_rm.rel.clone(),
|
||||
id: id.clone(),
|
||||
},
|
||||
state_rm.target.remove_link(id.clone()),
|
||||
"Public link removed",
|
||||
"Couldn't remove the link",
|
||||
None,
|
||||
@@ -407,12 +499,7 @@ pub(crate) fn share_dialog_create_link(
|
||||
) {
|
||||
let rx = spawn_request(
|
||||
state.ui.dirs.control_socket(),
|
||||
Request::CreatePublicLink {
|
||||
path: state.rel.clone(),
|
||||
role,
|
||||
password,
|
||||
expires: None,
|
||||
},
|
||||
state.target.create_link(role, password),
|
||||
);
|
||||
let state = state.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
//! The per-file **Versions** dialog: what Proton Drive still holds of a file's
|
||||
//! earlier content, and the three things a user can do with one.
|
||||
//!
|
||||
//! Every row is one revision the server kept. Restoring re-points the file at an
|
||||
//! old revision (server-side, so nothing is uploaded and the mount may take a
|
||||
//! moment to catch up); saving writes an old revision out to a local file
|
||||
//! without touching the live one; deleting is permanent, and the active revision
|
||||
//! cannot be deleted at all — that row simply has no delete button.
|
||||
|
||||
use crate::*;
|
||||
|
||||
use pdfs_core::control::RevisionInfo;
|
||||
|
||||
/// How an open Versions dialog addresses its file — the same split as
|
||||
/// [`ShareTarget`](super::share_dialog::ShareTarget), for the same reason: a
|
||||
/// node reached from Shared or a device folder may have no path in the primary
|
||||
/// mount's namespace, and an older daemon must reject the request rather than
|
||||
/// resolve an empty path to the mount root.
|
||||
pub(crate) enum VersionTarget {
|
||||
Path(String),
|
||||
Uid(String),
|
||||
}
|
||||
|
||||
impl VersionTarget {
|
||||
fn list(&self) -> Request {
|
||||
match self {
|
||||
VersionTarget::Path(path) => Request::ListRevisions { path: path.clone() },
|
||||
VersionTarget::Uid(uid) => Request::ListRevisionsByUid { uid: uid.clone() },
|
||||
}
|
||||
}
|
||||
|
||||
fn restore(&self, revision_id: String) -> Request {
|
||||
match self {
|
||||
VersionTarget::Path(path) => Request::RestoreRevision {
|
||||
path: path.clone(),
|
||||
revision_id,
|
||||
},
|
||||
VersionTarget::Uid(uid) => Request::RestoreRevisionByUid {
|
||||
uid: uid.clone(),
|
||||
revision_id,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn delete(&self, revision_id: String) -> Request {
|
||||
match self {
|
||||
VersionTarget::Path(path) => Request::DeleteRevision {
|
||||
path: path.clone(),
|
||||
revision_id,
|
||||
},
|
||||
VersionTarget::Uid(uid) => Request::DeleteRevisionByUid {
|
||||
uid: uid.clone(),
|
||||
revision_id,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn save_as(&self, revision_id: String, dest: String) -> Request {
|
||||
match self {
|
||||
VersionTarget::Path(path) => Request::SaveRevisionAs {
|
||||
path: path.clone(),
|
||||
revision_id,
|
||||
dest,
|
||||
},
|
||||
VersionTarget::Uid(uid) => Request::SaveRevisionAsByUid {
|
||||
uid: uid.clone(),
|
||||
revision_id,
|
||||
dest,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The state behind an open Versions dialog, so an action can rebuild the list
|
||||
/// in place instead of tearing the dialog down.
|
||||
pub(crate) struct VersionsDialog {
|
||||
pub(crate) ui: Rc<Ui>,
|
||||
pub(crate) target: VersionTarget,
|
||||
/// The file's name, for the save dialog's suggested filename and for toasts.
|
||||
pub(crate) name: String,
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
||||
}
|
||||
|
||||
/// Open the Versions dialog for a file.
|
||||
pub(crate) fn open_versions_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
if !*ui.mounted.borrow() {
|
||||
toast_error(ui, "Can't load versions", "Proton Drive isn't connected.");
|
||||
return;
|
||||
}
|
||||
if entry.is_dir {
|
||||
toast_error(ui, "No versions", "Folders don't have versions.");
|
||||
return;
|
||||
}
|
||||
let target = if entry.path.is_empty() {
|
||||
VersionTarget::Uid(entry.uid.clone())
|
||||
} else {
|
||||
VersionTarget::Path(entry_rel(ui, entry))
|
||||
};
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
let header = adw::HeaderBar::new();
|
||||
header.set_title_widget(Some(&adw::WindowTitle::new("Versions", &entry.name)));
|
||||
toolbar.add_top_bar(&header);
|
||||
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title("Version history")
|
||||
.description("Proton Drive keeps earlier versions of a file until you delete them.")
|
||||
.build();
|
||||
|
||||
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
content.set_margin_top(12);
|
||||
content.set_margin_bottom(12);
|
||||
content.set_margin_start(12);
|
||||
content.set_margin_end(12);
|
||||
content.append(&group);
|
||||
let clamp = adw::Clamp::builder().child(&content).build();
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
toolbar.set_content(Some(&scroll));
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Versions")
|
||||
.content_width(520)
|
||||
.content_height(520)
|
||||
.child(&toolbar)
|
||||
.build();
|
||||
|
||||
let state = Rc::new(VersionsDialog {
|
||||
ui: ui.clone(),
|
||||
target,
|
||||
name: entry.name.clone(),
|
||||
group,
|
||||
rows: RefCell::new(Vec::new()),
|
||||
});
|
||||
|
||||
versions_dialog_reload(&state);
|
||||
dialog.present(ui_window(ui).as_ref());
|
||||
}
|
||||
|
||||
/// Re-fetch the file's revisions and rebuild the rows.
|
||||
pub(crate) fn versions_dialog_reload(state: &Rc<VersionsDialog>) {
|
||||
let rx = spawn_request(state.ui.dirs.control_socket(), state.target.list());
|
||||
let state = state.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Revisions { items })) => repaint_versions(&state, &items),
|
||||
Ok(Ok(Response::Error { message, .. })) => {
|
||||
toast_error(&state.ui, "Couldn't load versions", &message)
|
||||
}
|
||||
_ => toast_error(
|
||||
&state.ui,
|
||||
"Couldn't load versions",
|
||||
"The mount service didn't respond.",
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Rebuild the version rows from a fresh listing.
|
||||
fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
|
||||
for row in state.rows.borrow_mut().drain(..) {
|
||||
state.group.remove(&row);
|
||||
}
|
||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||
if items.is_empty() {
|
||||
let row = dim_row("No earlier versions.");
|
||||
state.group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
*state.rows.borrow_mut() = rows;
|
||||
return;
|
||||
}
|
||||
|
||||
for item in items {
|
||||
// The uploader's claimed plaintext size is the one that matches what the
|
||||
// user sees in a listing; the storage figure counts ciphertext.
|
||||
let size = item
|
||||
.claimed_size
|
||||
.filter(|size| *size >= 0)
|
||||
.unwrap_or(item.size_on_storage)
|
||||
.max(0) as u64;
|
||||
let mut subtitle = format!("{} · {}", activity_time(item.created), human_bytes(size));
|
||||
if let Some(email) = &item.signed_by {
|
||||
subtitle.push_str(" · ");
|
||||
subtitle.push_str(email);
|
||||
}
|
||||
let title = if item.is_active {
|
||||
"Current version".to_string()
|
||||
} else {
|
||||
format!("Version {}", item.id)
|
||||
};
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(title)
|
||||
.subtitle(subtitle)
|
||||
.build();
|
||||
|
||||
let save = gtk4::Button::builder()
|
||||
.icon_name("document-save-symbolic")
|
||||
.tooltip_text("Save a copy…")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
save.add_css_class("flat");
|
||||
let state_save = state.clone();
|
||||
let id = item.id.clone();
|
||||
save.connect_clicked(move |_| prompt_save_version(&state_save, &id));
|
||||
row.add_suffix(&save);
|
||||
|
||||
if !item.is_active {
|
||||
let restore = gtk4::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.tooltip_text("Restore this version")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
restore.add_css_class("flat");
|
||||
let state_restore = state.clone();
|
||||
let id = item.id.clone();
|
||||
restore.connect_clicked(move |_| prompt_restore_version(&state_restore, &id));
|
||||
row.add_suffix(&restore);
|
||||
|
||||
let delete = gtk4::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.tooltip_text("Delete this version permanently")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
delete.add_css_class("flat");
|
||||
let state_delete = state.clone();
|
||||
let id = item.id.clone();
|
||||
delete.connect_clicked(move |_| prompt_delete_version(&state_delete, &id));
|
||||
row.add_suffix(&delete);
|
||||
}
|
||||
|
||||
state.group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
}
|
||||
*state.rows.borrow_mut() = rows;
|
||||
}
|
||||
|
||||
/// Confirm, then restore. Confirmed because it replaces the file's content for
|
||||
/// every device on the account, not only this one.
|
||||
fn prompt_restore_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Restore version")
|
||||
.body(format!(
|
||||
"Make this version the current content of “{}”? The version it replaces stays in the history.",
|
||||
state.name
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("restore", "Restore");
|
||||
dialog.set_response_appearance("restore", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
|
||||
let window = ui_window(&state.ui);
|
||||
let state = state.clone();
|
||||
let revision_id = revision_id.to_string();
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
if resp != "restore" {
|
||||
return;
|
||||
}
|
||||
versions_dialog_op(
|
||||
&state,
|
||||
state.target.restore(revision_id.clone()),
|
||||
// The server applies the swap in the background, so the wording
|
||||
// promises the request, not the result.
|
||||
"Restoring that version",
|
||||
"Couldn't restore that version",
|
||||
);
|
||||
});
|
||||
dialog.present(window.as_ref());
|
||||
}
|
||||
|
||||
/// Confirm, then permanently delete one revision.
|
||||
fn prompt_delete_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Delete version")
|
||||
.body("Delete this version permanently? Its content can't be recovered.")
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("delete", "Delete");
|
||||
dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
|
||||
let window = ui_window(&state.ui);
|
||||
let state = state.clone();
|
||||
let revision_id = revision_id.to_string();
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
if resp != "delete" {
|
||||
return;
|
||||
}
|
||||
versions_dialog_op(
|
||||
&state,
|
||||
state.target.delete(revision_id.clone()),
|
||||
"Version deleted",
|
||||
"Couldn't delete that version",
|
||||
);
|
||||
});
|
||||
dialog.present(window.as_ref());
|
||||
}
|
||||
|
||||
/// Ask where to write an old version, then have the daemon download it there.
|
||||
///
|
||||
/// The daemon writes the file, so the chosen location has to be one it can see:
|
||||
/// a path from the portal-free `FileDialog` is a real filesystem path, which it
|
||||
/// can.
|
||||
fn prompt_save_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
||||
let window = ui_window(&state.ui);
|
||||
let dialog = gtk4::FileDialog::builder()
|
||||
.title("Save version")
|
||||
.initial_name(&state.name)
|
||||
.build();
|
||||
let state = state.clone();
|
||||
let revision_id = revision_id.to_string();
|
||||
dialog.save(window.as_ref(), gio::Cancellable::NONE, move |res| {
|
||||
let Ok(file) = res else { return };
|
||||
let Some(dest) = file.path().and_then(|p| p.to_str().map(str::to_string)) else {
|
||||
toast_error(
|
||||
&state.ui,
|
||||
"Couldn't save that version",
|
||||
"That location isn't a local file.",
|
||||
);
|
||||
return;
|
||||
};
|
||||
versions_dialog_op(
|
||||
&state,
|
||||
state.target.save_as(revision_id.clone(), dest),
|
||||
"Version saved",
|
||||
"Couldn't save that version",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Send one version request, toast the outcome, and refresh the list.
|
||||
fn versions_dialog_op(state: &Rc<VersionsDialog>, request: Request, done: &str, failed: &str) {
|
||||
let rx = spawn_request(state.ui.dirs.control_socket(), request);
|
||||
let state = state.clone();
|
||||
let done = done.to_string();
|
||||
let failed = failed.to_string();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
toast(&state.ui, &done);
|
||||
versions_dialog_reload(&state);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) => toast_error(&state.ui, &failed, &message),
|
||||
_ => toast_error(&state.ui, &failed, "The mount service didn't respond."),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -76,6 +76,31 @@ sequenceDiagram
|
||||
FUSE-->>User: returns child inode metadata (attributes, TTL)
|
||||
```
|
||||
|
||||
### Access Classification and Enforcement
|
||||
|
||||
Every `Entry` carries an `Access` (`Owner | Editor | Viewer | Unknown`), inherited from its parent at intern time rather than resolved per node — a child is always interned from its parent's listing, so one edge lookup answers it. A known share root takes its access from the persisted `share_access` table instead, which is also what makes the classification correct offline.
|
||||
|
||||
Two rules decide the cases inheritance cannot:
|
||||
|
||||
* **Not under a share, no role → `Owner` (fail open).** My Files and device folders are owned content; regressing this denies ordinary writes.
|
||||
* **Under a share with no usable role → `Viewer` (fail closed).** An unrecognised permission mask is never degraded into a guess.
|
||||
|
||||
A node whose parent is not resident is the awkward case: a device folder's parent is the device root, which is never persisted as a node, so the whole subtree hydrates parentless. It resolves the way the persisted authority (`Db::effective_node_access`) does — a recorded share row above it wins, otherwise fail open on the mount's own volume and closed on a foreign one (`docs/BUGS.md` B79).
|
||||
|
||||
Enforcement is three layers, and only the third closes B34:
|
||||
|
||||
1. **Mode bits.** `attr()` returns `0o555`/`0o444` for a non-writable entry, and both mount paths set `MountOption::DefaultPermissions`, so the *kernel* refuses `open(O_WRONLY)`, `access(W_OK)` and namespace operations for any non-root process.
|
||||
2. **Handler gates.** `EACCES` in `create`/`mkdir`/`unlink`/`rmdir`/`rename` (parent-writable; both parents for a rename) and in `open`-for-write/`write`/`setattr`/`fallocate`. Covers root and stale attribute TTLs.
|
||||
3. **Queue guards.** `Core::require_uid_writable` admits a mutation only when the persisted authority *and* every live inode space agree the uid is writable. Nothing reaches `pending_op` otherwise, so the perpetual failing drain that B34 describes cannot occur even if a handler check is missed. The intersection is across mounts because a uid may be resident in more than one inode space.
|
||||
|
||||
`EACCES`, not `EROFS`, for a read-only subtree inside a read-write mount: `EROFS` means "read-only filesystem" and misleads the heuristics in `cp`, `rsync` and `git`.
|
||||
|
||||
### Local Locations
|
||||
|
||||
`mount` is a presentation table: one row per local place this client occupies — the primary My Files session, and each device folder in mirror or on-demand mode. `sync_folder` remains the sync engine's own table (`sync_entry` is FK'd to it) and `MountKind::Device { sync_folder_id }` is the join. `Request::ListLocations` serves the table to `pdfs locations` and the GUI's Locations page.
|
||||
|
||||
A mirror folder is a plain local directory with no FUSE session, so a row can legitimately describe a location that is not mounted — which is why the page is called *Locations* rather than *Mounts*. The primary mountpoint stays in `AppConfig`, with its `mount` row written at daemon start as a projection of it, so there are not two sources of truth for that path.
|
||||
|
||||
---
|
||||
|
||||
## 3. Read Path & Block Caching Pipeline
|
||||
|
||||
+180
-5
@@ -12,6 +12,133 @@ Conventions:
|
||||
|
||||
---
|
||||
|
||||
## B79 — Every write in an on-demand device-folder mount fails EACCES
|
||||
|
||||
**Status:** Fixed (verified live 2026-07-31, running in a locally rebuilt 1.2.1
|
||||
package since)
|
||||
**Found:** 2026-07-31, user on packaged 1.2.1: `touch ~/Documents/test` →
|
||||
`Permission denied`, while the same operation under `~/ProtonDrive` succeeds.
|
||||
**Where:** `crates/pdfs-fuse/src/state.rs`, `State::hydrate_access`
|
||||
|
||||
Not a mode-bit problem — the mount root reads `drwxr-xr-x` and the kernel's
|
||||
`default_permissions` check passes. The denial comes from the queue guard:
|
||||
`serve_create` → `Core::require_uid_writable`, which **intersects the access
|
||||
every live inode space reports for that uid** (`lib.rs:721`) before anything
|
||||
reaches `pending_op`.
|
||||
|
||||
The on-demand fork itself says `Owner`. The **primary** state says `Unknown`:
|
||||
|
||||
- `Core::hydrate` (`lib.rs:1012`) loads *every* `nodes` row into the primary
|
||||
state, including the subtrees that belong to on-demand device folders — they
|
||||
are on the same volume, in the same table.
|
||||
- A device folder's `parent_uid` is the **device root**, which is never
|
||||
persisted as a node. In this account's DB all six device-folder roots
|
||||
(`Downloads`, `Pictures`, `Music`, `Documents`, `Videos`, `narl`) have
|
||||
`parent_uid = <device root>` with no matching row, so pass 2 parks them at
|
||||
`ORPHAN_INO`.
|
||||
- `hydrate_access` then hit its "parent is not resident" branch, which had no
|
||||
answer and fell back to `Access::Unknown` — fail closed.
|
||||
- `Unknown` is not writable, so the intersection denies every create, mkdir,
|
||||
write, rename, setattr and trash in every on-demand mount, permanently: those
|
||||
entries are never re-interned in the primary state, so nothing recomputes them.
|
||||
|
||||
`~/ProtonDrive` was unaffected because its nodes descend from `ROOT_INO`, which
|
||||
`ProtonFs::new` hardcodes to `Owner`.
|
||||
|
||||
Two facts make this a *classification* bug rather than a policy one: the
|
||||
persisted authority `Db::effective_node_access` already answers `Owner` for
|
||||
exactly these uids (no `share_access` row covers them — the table holds only
|
||||
`virtual~sharedwithme` and one accepted share), and the design table in
|
||||
`mount-architecture.md` §2.2 says *not under a share, no role → Owner, fail
|
||||
open*. The in-memory path disagreed with both.
|
||||
|
||||
**Fix:** the non-resident-parent branch now mirrors the persisted authority — a
|
||||
recorded `share_access` row above the node still wins, otherwise fail **open**
|
||||
to `Owner` on the mount's own volume and fail **closed** to `Unknown` on a
|
||||
foreign one (`State::is_own_volume`). Foreign-volume shared content keeps its
|
||||
fail-closed behavior, which is what B34 needs.
|
||||
|
||||
**Second-order effect, also fixed by the same change:** `downgrade_known_shared_access`
|
||||
selects roots with `entry.access != Access::Owner`, so a `ScopeAccessLost` /
|
||||
`SharedWithMeUpdated` event would additionally have force-downgraded every
|
||||
device-folder tree to `Viewer` in memory.
|
||||
|
||||
**Tests:** `state::tests::own_volume_nodes_without_a_resident_parent_stay_owned`,
|
||||
`foreign_volume_nodes_without_a_resident_parent_fail_closed`,
|
||||
`persisted_authority_outranks_the_own_volume_fail_open`. The first fails on the
|
||||
pre-fix branch with `left: Unknown, right: Owner`. The standing live guard is the
|
||||
`regression B79` acceptance case, which finds a mounted on-demand device folder
|
||||
through `pdfs locations --json` and writes into it (skipping when this machine
|
||||
has none).
|
||||
|
||||
**Verified live (2026-07-31):** the packaged 1.2.1 daemon denies
|
||||
`touch ~/Documents/pdfs-b79-test`, `mkdir`, and `>` redirect while
|
||||
`~/ProtonDrive` accepts them; a local `--release` build of this fix accepts all
|
||||
three on the same account and DB, the write drains (`pending_op` back to 0, the
|
||||
new nodes carry real `G88km…` remote uids rather than local ones), and the
|
||||
daemon log has no `403`/EACCES. `~/ProtonDrive/Shared with me/` stayed
|
||||
`dr-xr-xr-x`, and the accepted editor-role share under it stayed writable — the
|
||||
foreign-volume fail-closed path is unchanged.
|
||||
|
||||
**Side note for whoever runs this next:** stopping the daemon and touching a
|
||||
file in an on-demand *mountpoint* while it is unmounted leaves that file in the
|
||||
local directory, and `restore_ondemand_mounts` then refuses to mount over the
|
||||
non-empty dir (`WARN … local dir is not empty; refusing to mount over it`).
|
||||
Empty the directory and restart the unit.
|
||||
|
||||
---
|
||||
|
||||
## B78 — Profile backup fails: Cannot create file at the root of a device
|
||||
|
||||
**Status:** Fixed (verified live 2026-07-31). Same defect as **B68**, which was
|
||||
found first on the managed-live matrix; B68's second half — making backup health
|
||||
visible — is still open.
|
||||
**Found:** 2026-07-29, user reported `upload profile: proton api error NotEnoughPermissions (http 422): Cannot create file at the root of a device`
|
||||
|
||||
The background task that backs up the machine's profile (sync folder mappings, pins, cache budget) currently attempts to write `profile.json` directly to the device root node (`device.root_uid`) on the Drive backend. The Proton Drive API has started enforcing a restriction that files cannot be created directly at the root of a device; they must be placed inside a folder.
|
||||
|
||||
**Consequence:** The upload is rejected with HTTP 422. The daemon retries when changes happen but the profile is never successfully backed up. Local changes (pinning, adding a sync folder) still take effect locally, but they will not be restorable on a new machine.
|
||||
|
||||
**Fix applied (matches the original `RECOVERY.md` spec):** the profile now lives
|
||||
in a `.proton-drive-linux` folder inside the device root.
|
||||
|
||||
- `pdfs_core::profile::PROFILE_DIR_NAME` — the new constant, next to
|
||||
`PROFILE_FILE_NAME`.
|
||||
- `profile.rs:ensure_profile_dir` — reuses the folder by name via the existing
|
||||
`find_device_child_folder`, creating it on the first save. Reuse-by-identity
|
||||
matters: a second folder of the same name would split the record in two with
|
||||
nothing to say which is current.
|
||||
- `profile.rs:save_profile` — both upload branches (new file, new revision) now
|
||||
address the folder, never the device root.
|
||||
- `profile.rs:load_profile` — reads the folder first, then falls back to a
|
||||
device-root `profile.json` written by an older client. The fallback is
|
||||
read-only and the legacy document is **not** trashed: another machine may
|
||||
still be running a pre-fix client that reads it.
|
||||
- `profile.rs:list_restorable_folders` — filters `.proton-drive-linux` out, or
|
||||
the restore picker would offer to sync the bookkeeping back onto the machine
|
||||
it describes.
|
||||
- `devices.rs:add_sync_folder` — refuses a local folder named
|
||||
`.proton-drive-linux`, which would otherwise be reused as the remote profile
|
||||
folder by name and then reconciled (i.e. overwritten) by the sync engine.
|
||||
|
||||
**Verified live (2026-07-31)** against the real account: first save logged
|
||||
`created device profile folder name=".proton-drive-linux"` then
|
||||
`profile backed up folders=6` with no 422; a second trigger reused the folder
|
||||
(no second create) and uploaded a revision; `pdfs sync restore --json` lists
|
||||
exactly the six device folders and not the profile folder.
|
||||
|
||||
**Tests:** `profile::tests::the_profile_is_never_uploaded_to_the_device_root`,
|
||||
`loading_falls_back_to_a_legacy_device_root_profile`,
|
||||
`the_profile_folder_is_not_offered_as_user_data` — source-level guards in the
|
||||
style of `lib.rs`'s queue-guard ordering tests, because both upload branches are
|
||||
pure network calls with nothing to assert offline.
|
||||
|
||||
**Not covered by this fix:** B68 also asks for backup *health* to be visible
|
||||
(currently a `WARN` in the log and nothing in the UI), and for the
|
||||
replacement-machine / fresh-state restore to be re-run end to end.
|
||||
|
||||
---
|
||||
|
||||
## B1 — FUSE rename loses the file (data loss)
|
||||
|
||||
**Status:** Fixed (verified 2026-07-20)
|
||||
@@ -1432,12 +1559,22 @@ added later, but it must retain the row and blob.
|
||||
|
||||
## B34 — Writable POSIX Exposure of Read-Only Shared Folders (MED-07)
|
||||
|
||||
**Status:** Open
|
||||
**Status:** Fixed in code by P2/P3 (2026-07-29); P3 now exposes the shared tree, while credentialed
|
||||
second-account and live FUSE acceptance remain pending P7
|
||||
**Found:** 2026-07-21, multi-agent permissions audit (`audit_bugs.md` MED-07)
|
||||
**Where:** `crates/pdfs-fuse/src/lib.rs`, `crates/pdfs-fuse/src/sharing.rs`
|
||||
|
||||
**Cause:** Shared "Viewer" (read-only) folders are exposed via POSIX with write permissions (`0755`). Local writes succeed initially on the mount but fail perpetually during background online drain with `403 Forbidden` errors.
|
||||
|
||||
**Fix:** P2 persists share authority in schema V17 `share_access`, inherits it through resident
|
||||
entries, exposes read-only POSIX modes, and gates mutations at handler, queue, and drain boundaries.
|
||||
Downgrades fail closed and are not acknowledged past failed durable state updates.
|
||||
|
||||
**Verification:** The local workspace passes formatting, locked checks, clippy with warnings denied,
|
||||
and all 347 workspace tests, including P3 virtual-tree materialization and access inheritance plus
|
||||
mutation gating, queued-operation authorization, and downgrade failure paths. Credentialed
|
||||
second-account and live FUSE acceptance have not been run and remain required in P7.
|
||||
|
||||
---
|
||||
|
||||
## B35 — IPC Unix Socket Creation Permission Race (MED-08)
|
||||
@@ -2095,7 +2232,9 @@ duplicates, renamed folders, multiple devices, and byte-identical restoration.
|
||||
|
||||
## B68 — Profile Backup Cannot Be Written to the Device Root
|
||||
|
||||
**Status:** Open — live-reproduced 2026-07-22
|
||||
**Status:** Partly fixed — the destination is fixed and verified live
|
||||
(2026-07-31, see **B78**); backup health is still invisible in the UI and the
|
||||
replacement-machine restore has not been re-run
|
||||
**Found:** complete managed-live mode matrix against the 1.0.0 release binary
|
||||
**Where:** `crates/pdfs-fuse/src/profile.rs`, profile backup destination
|
||||
|
||||
@@ -2356,12 +2495,48 @@ correct behaviour for the first live pass is to trash nothing.
|
||||
|
||||
## B72 — `pdfs trash` never returns
|
||||
|
||||
**Status:** Open — found 2026-07-26.
|
||||
**Status:** Hardened in-tree 2026-07-26 — the hang is gone by construction; the
|
||||
underlying slowness of the first refresh is still unmeasured (live-pending).
|
||||
**Found:** capturing a trash baseline before the B69/B70 live validation. `pdfs trash`
|
||||
produced no output and was still running when killed at 120 s. Daemon was healthy
|
||||
and every other CLI call (`pdfs sync list`, `pdfs --version`) returned promptly.
|
||||
**Where:** unconfirmed — either the `Request` handler in `crates/pdfs-fuse/src/control.rs`
|
||||
or the CLI's response read in `crates/pdfs-cli/src/main.rs`.
|
||||
**Where:** `Core::list_trash` (`crates/pdfs-fuse/src/lib.rs`). Not the CLI: the
|
||||
client's 120 s `READ_TIMEOUT` (`pdfs-core/src/control.rs`) is what ends the call,
|
||||
and the daemon never writes a response line.
|
||||
|
||||
**Root cause.** The trash of this account had never been fetched successfully —
|
||||
`trash` table empty, no `trash_synced_ms` in `sync_state`. `list_trash` therefore
|
||||
always took its "never fetched → `rt.block_on(refresh_trash())`" branch, which:
|
||||
|
||||
- materialized the *entire* trash in one `enumerate_nodes` call (one S2K unlock
|
||||
per node) before persisting anything, so nothing was ever saved and no later
|
||||
request could be served from the DB;
|
||||
- had no single-flight guard on that branch (only `spawn_trash_refresh` did), so
|
||||
every attempt started another full refresh;
|
||||
- was never cancelled when the requester timed out, because the control handler
|
||||
sits in `block_on` on its own thread and nothing signals it.
|
||||
|
||||
So each look at the trash added a permanent, full-cost refresh to the daemon.
|
||||
Measured on the live daemon after a handful of GUI/CLI attempts: **11 stale
|
||||
`pdfs-control` threads**, RSS 2.9 GB. Concurrent with a drain backlog that was
|
||||
uploading conflict copies continuously (1991 `Conflict` activity rows, multi-GB
|
||||
uploads), none of the refreshes ever finished.
|
||||
|
||||
**Fix (in-tree).** `list_trash` never blocks on the whole refresh: it kicks the
|
||||
single-flighted background refresh, waits at most `TRASH_FIRST_WAIT` (20 s, well
|
||||
under the front-end read timeout) on a `Notify`, and answers with whatever has
|
||||
materialized. `refresh_trash` now materializes in `TRASH_MATERIALIZE_CHUNK` (150)
|
||||
batches, persisting the cumulative listing after each one, and logs the uid count
|
||||
plus per-batch and total elapsed at INFO/DEBUG — previously the whole path was
|
||||
silent, which is why the stall could not be located from the journal.
|
||||
|
||||
**Still open:** *why* one refresh is slow enough to matter. The new INFO lines
|
||||
(`trash refresh: enumerated` / `batch` / `done`) answer that on the next run:
|
||||
whether `enumerate_trash_node_uids` returns at all, how many nodes the trash
|
||||
holds, and the per-batch cost. If the count is large, materializing with
|
||||
`enumerate_nodes_light` (skips the per-file node-key S2K; `size` would fall back
|
||||
to on-storage size) is the next lever.
|
||||
|
||||
**Impact:** the trash listing is the user's recovery path after any destructive
|
||||
operation, including the B71 sweep. It needs to work before the sweep is allowed
|
||||
to trash anything.
|
||||
|
||||
+45
-4
@@ -7,21 +7,20 @@ This document summarizes the work still pending after the 1.0.0 release branch a
|
||||
## 1. Core Feature Roadmap (Horizon Tasks)
|
||||
Consolidated from the historical feature roadmap; current bug status lives in [`BUGS.md`](BUGS.md).
|
||||
|
||||
* **Local Cache & Metadata Encryption at Rest (Priority 5 / Horizon 1)**: Encrypt the SQLite database `cache.db` on disk using SQLCipher (`rusqlite` feature `bundled-sqlcipher`), and encrypt raw content cache blocks (`content/blocks/` and `content/scratch/`) using a fast symmetric scheme like AES-GCM or ChaCha20-Poly1305. The encryption key should be derived from the OS Keyring.
|
||||
* **Local Cache & Metadata Encryption at Rest (Priority 5 / Horizon 1)**: Encrypt the SQLite database `cache.db` on disk using SQLCipher (`rusqlite` feature `bundled-sqlcipher`), and encrypt raw content cache blocks (`content/blocks/` and `content/scratch/`) using a fast symmetric scheme like AES-GCM or ChaCha20-Poly1305. The encryption key should be derived from the OS Keyring. (The SDK entity cache in `sdk_cache.db` is already encrypted at rest — see *Persistent SDK Entity Cache* below — but `cache.db` and the content cache are not.)
|
||||
* **Active Bandwidth Throttling & Traffic Shaping (Horizon 1)**: Add speed governors for daemon uploads and downloads. Support configuration variables `max_upload_rate_kbps` and `max_download_rate_kbps` in `config.json` that can be dynamically adjusted over the Unix control socket.
|
||||
* **Interactive & Policy-Based Conflict Resolution (Horizon 1)**: Implement configurable conflict resolution policies (`rename-local`, `rename-remote`, `prefer-local`, `prefer-remote`, `interactive`). Add a "Conflicts" tab to the GUI to notify the user, block sync, and launch external visual diff tools (like Meld or KDiff3).
|
||||
* **Multi-Account / Multi-Profile Support (Horizon 3)**: Enable running multiple profiles concurrently. Support separate configuration namespaces (e.g. `profiles/<profile_name>/`), independent SQLite databases, distinct mount paths (e.g. `~/ProtonDrive/Personal` and `~/ProtonDrive/Work`), and multiple daemon processes.
|
||||
* **Custom FUSE Mount Options (Horizon 1)**: Expose custom mounting parameters in configuration (e.g. `ro` for read-only, `direct_io` to bypass kernel caching, `allow_other`). Auto-generate systemd user mount units upon sync folder configuration.
|
||||
* **Symbolic Link & Hard Link Virtualization (Horizon 2)**: Add symbolic link virtualization inside the metadata store (`NodeType::Symlink`) by intercepting `readlink(2)` and `symlink(2)`. Targets should sync remotely as small encrypted metadata placeholder files on Drive (`proton-vfs-symlink: <target>`).
|
||||
* **Desktop File Manager Integration (Horizon 2)**: Develop shell extension plugins for popular Linux file managers (Nautilus Python plugin, Dolphin Service Menus, Thunar custom actions) to offer context-menu shortcuts ("Pin", "Unpin", "Copy Share Link", "View Version History") and file overlay sync badges.
|
||||
* **Integrated Photo Gallery Enhancements (Horizon 2)**: Extract and display Exif metadata (camera model, exposure, coordinates) from photos locally, support creating and editing Proton Photo Albums, build a date-based timeline scrollbar, and create an auto-upload Pictures folder pipeline.
|
||||
* **Integrated Photo Gallery Enhancements (Horizon 2)**: Support creating and editing Proton Photo Albums (album *writes* are unported in the SDK too) and create an auto-upload Pictures folder pipeline. Exif display, the date-based timeline scrubber and favourites are implemented.
|
||||
* **Random-Access Media Streaming seek support (Horizon 2)**: Intercept out-of-order sparse reads (player seeks) in `pdfs-fuse` and prioritize downloading blocks around the seek offset, aborting/postponing sequential pre-fetches.
|
||||
* **Dynamic Sync Dashboard & Queue Visualization (Horizon 2)**: GUI real-time transfer list (progress bars, speed, ETA), sync history feed, storage usage breakdown, and global pause/resume sync controls.
|
||||
* **Interactive Terminal UI (TUI) Mode (Horizon 3)**: Implement `pdfs tui` using the `ratatui` crate to show transfer speed, sync status, active transfer queue with progress bars, and scrolling daemon logs.
|
||||
* **Shutdown Safety & Write Queue Visibility (Horizon 1 / P4)**: Register systemd inhibitor locks in `pdfs-fuse` when there are outstanding staging writes to prevent shutdown or sleep, pop up warnings when exiting the GUI, and build a blocking `pdfs sync flush` command.
|
||||
* **Block-Level Deduplication (Horizon 3)**: Transition to a Content-Addressable block cache where cached blocks are stored on disk by their SHA-256 hash. Map logical ranges in the database: `(node_uid, block_idx) -> block_hash` to avoid storing or downloading identical blocks.
|
||||
* **Pre-emptive Sync Debouncing & File System Events (Horizon 3)**: Group filesystem write events on paths and delay sync queue insertion until a quiet period has elapsed (e.g. 5 seconds of inactivity) to prevent thrashing the Proton API.
|
||||
* **File Version History (Horizon 2)**: Expose past file revisions and version history inside the GUI browser tabs.
|
||||
|
||||
---
|
||||
|
||||
@@ -58,7 +57,9 @@ Derived from: [`BUGS.md`](BUGS.md)
|
||||
* **B56–B60 — fail-closed state and IPC fixes**: Future schemas, event cursor advancement, socket limits, private-directory validation, and atomic config saves are implemented. Malformed-schema, database-full, connection-flood, wrong-owner, and concurrent/faulted-save verification remains.
|
||||
* **B61 — credential rotation persistence**: Surface and retry keyring persistence failures so a rotated single-use refresh token cannot be lost on restart.
|
||||
* **B62–B65 — release assurance**: Clean-install package verification, complete version/protocol identity, recovery sign-off, advisory/license policy, SBOM, provenance, checksums, and signatures remain.
|
||||
* **B66–B68 — lifecycle and recovery**: Add queue flush/shutdown protection, explicit device adoption during fresh-state restore, and move profile backup below a writable remote folder.
|
||||
* **B66–B67 — lifecycle and recovery**: Add queue flush/shutdown protection and explicit device adoption during fresh-state restore.
|
||||
* **B68/B78 — profile backup**: the destination is fixed and verified live — `profile.json` now lives in `.proton-drive-linux/` under the device root, since the API refuses file creation at the root itself. What remains from B68 is making backup *health* visible (today a failure is a `WARN` in the log and nothing in the UI) and re-running the replacement-machine restore end to end.
|
||||
* **B79 — cross-mount permission classification**: fixed and verified live; the standing guard is the `regression B79` acceptance case. The second-account cases it neighbours (`regression B34`) still cannot run — see below.
|
||||
|
||||
---
|
||||
|
||||
@@ -76,6 +77,7 @@ Derived from: [`TESTING.md`](TESTING.md) and [`RECOVERY.md`](RECOVERY.md)
|
||||
3. Wipe the local state directory and SQLite cache.
|
||||
4. Restart the daemon.
|
||||
5. Run `pdfs sync restore` and assert the recovered filesystem matches the source byte-for-byte.
|
||||
* **Second-account sharing run (mount-architecture.md P7)**: The only outstanding verification for the mount/sharing work. One account covers `context_share_id`, the shared-with-me listing, sharing from device folders and the Locations page — all confirmed. A **second account** is required for the shared-tree rendering under a foreign volume, `accept_invitation`, `update_member_role`, the `regression B34` viewer cases (which skip cleanly today), and above all an **editor-role write into a foreign share**, the only thing that validates `membership_address_for`.
|
||||
* **Managed live mode matrix**: Before release, run `scripts/fuse-acceptance.sh --managed-live <empty-a> <empty-b>` with `PDFS_ACCEPTANCE_ONLY` unset. It validates on-demand/on-demand, on-demand/mirror, mirror/on-demand, and mirror/mirror transitions.
|
||||
* **Crash-consistency matrix**: Inject failure after scratch/sidecar sync, each staging rename, directory sync, pending-op insert/commit, and source deletion; prove that a complete discoverable copy and either the old or new queue record always survives.
|
||||
|
||||
@@ -119,6 +121,45 @@ Derived from: [`BUGS.md`](BUGS.md)
|
||||
|
||||
## 6. Recently Implemented Features
|
||||
|
||||
### File Version History (Implemented)
|
||||
**Files**: [`revisions.rs`](../crates/pdfs-fuse/src/revisions.rs), [`control.rs`](../crates/pdfs-core/src/control.rs), [`versions_dialog.rs`](../crates/pdfs-gui/src/app/widgets/versions_dialog.rs)
|
||||
|
||||
Proton Drive keeps every revision a client committed; the daemon only ever addressed the active one, so a file overwritten by a sync pass could be recovered only from whatever the local `recovery/` directory happened to hold.
|
||||
|
||||
The control protocol gained `ListRevisions` / `RestoreRevision` / `DeleteRevision` / `SaveRevisionAs` (each with a `…ByUid` twin for nodes the primary mount cannot name), the CLI gained `pdfs versions list|restore|save|rm`, and the browser's details pane gained a **Versions** button opening a per-file dialog.
|
||||
|
||||
Three properties that shape the code:
|
||||
|
||||
- **A restore is server-side and asynchronous.** No content crosses the wire and nothing enters the drain queue; the server answers 202 and swaps the active revision in the background, so the daemon evicts the file's cached blocks and open readers rather than describing the new state, and the UI wording promises the request, not the result.
|
||||
- **The active revision cannot be deleted.** `Core::delete_revision_for_uid` checks that locally so the user gets a sentence rather than an API code, and the dialog gives the current row no delete button.
|
||||
- **Saving a version never overwrites.** `SaveRevisionAs` refuses an existing destination and removes a partial file if the download fails — a half-written export looks identical to a good one to every tool that opens it afterwards.
|
||||
|
||||
### Photo Favourites (Implemented)
|
||||
**Files**: [`photos.rs`](../crates/pdfs-fuse/src/photos.rs), [`photo_viewer.rs`](../crates/pdfs-gui/src/app/pages/photo_viewer.rs)
|
||||
|
||||
The gallery can mark and filter favourites, through the SDK's photo-tag API (`update_photos`). Schema **v20** adds `photos.favorite`; the flag is learned in the timeline enrichment pass that already resolves each photo's name and media type, and is kept across a refresh that could not resolve a photo — the same learned-and-kept rule as `media_type`. The lightbox carries a star toggle, the Photos header a favourites filter, and the CLI has `pdfs favorite <uid> [--remove]` plus `pdfs photos --favorites`.
|
||||
|
||||
Favouriting a photo that is not on this account's own photos volume (shared with us, or album-only) needs it re-encrypted for our timeline root, which the SDK does not implement; the daemon surfaces that as an error rather than silently doing nothing.
|
||||
|
||||
### Persistent SDK Entity Cache (Implemented)
|
||||
**Files**: [`sdkcache.rs`](../crates/pdfs-core/src/sdkcache.rs), [`auth.rs`](../crates/pdfs-core/src/auth.rs), [`background.rs`](../crates/pdfs-fuse/src/background.rs)
|
||||
|
||||
The SDK's Drive entity cache (decrypted node metadata: name, size, parent, signing share) defaulted to memory, so every daemon restart re-fetched and re-decrypted the tree the previous run had already walked. It is now backed by SQLite.
|
||||
|
||||
- **Its own file** (`sdk_cache.db` in the state directory), not `cache.db`: the daemon's `Db` is one `Mutex<Connection>` shared by every FUSE thread and the control socket, and this traffic is frequent, small and entirely reconstructible. A separate file also means no schema migration — the store can be deleted at any time.
|
||||
- **Encrypted at rest** by wrapping it in the SDK's `EncryptedCacheRepository`, keyed by the mailbox password. A password change reads as a cold cache (the SDK treats an undecryptable entry as a miss and clears the store), not as an error.
|
||||
- **Staleness** is closed by the event loop, which already replays from a persisted cursor and calls `invalidate_caches_for_event` for every event — including those raised while the daemon was down. The one case with no trail is a *seeded* cursor (first-ever mount, or a lost cursor), where the seed path now clears the store instead of trusting it. `auth::logout` removes the file.
|
||||
|
||||
Needed a small additive SDK change (`ProtonDriveClient::with_entity_repository`, 0.5.1): `with_entity_cache` is a constructor and so could not be combined with `with_key_salts`, which the daemon requires.
|
||||
|
||||
### Per-node Batch Outcomes (Implemented)
|
||||
**File**: [`batch.rs`](../crates/pdfs-core/src/batch.rs)
|
||||
|
||||
SDK 0.5.0 made `trash_nodes` / `restore_nodes` / `delete_nodes` report one outcome per node instead of failing the whole call (mirroring upstream's streamed `NodeActionResult`). `pdfs_core::batch::into_unit` collapses the single-node calls back to a `Result`; `batch::split` handles a collected batch.
|
||||
|
||||
The trash view's restore and permanent-delete use the SDK's **streaming** variants (`restore_nodes_streaming` / `delete_nodes_streaming`) and apply local state per node as each batch lands rather than after the last one. For a permanent delete — which is irreversible — that means a daemon interrupted mid-batch has forgotten exactly the nodes the server destroyed, no more and no fewer. Both report per-node failures and count only what actually succeeded; only a batch where nothing succeeded is an error.
|
||||
|
||||
|
||||
### Open-for-Write Deferral for Mirror Sync (Implemented)
|
||||
**File**: [`sync.rs`](../crates/pdfs-fuse/src/sync.rs)
|
||||
|
||||
|
||||
@@ -83,6 +83,31 @@ guard, and it was found by exactly this mechanism: the B70 case failed, and
|
||||
narrowing it produced a smaller reproduction that has its own case now. Expect a
|
||||
red run against a real mount until it is fixed.
|
||||
|
||||
**B79** (an on-demand device folder accepts writes) discovers its target from
|
||||
`pdfs locations --json` and skips when this machine has no mounted on-demand
|
||||
folder. It is the cheap standing guard against a *cross-mount* permission
|
||||
regression: the primary mount holds device-folder nodes too, and the queue guard
|
||||
intersects what every live inode space says about a uid, so a fail-closed
|
||||
classification in one mount denies writes in another.
|
||||
|
||||
**B34** (a viewer-role share is read-only) and **B34b** (an editor-role share
|
||||
still writes) locate their subject through `pdfs shared-with-me --json`, which
|
||||
now carries `role` and a mount-relative `path`. Both skip when the account has no
|
||||
accepted share at that role — B34 needs a **second account** to have shared a
|
||||
folder read-only, which is the one part of the sharing suite that cannot be
|
||||
self-served. B34b degrades gracefully in the other direction: a shared *file*
|
||||
gets its mode bits and `W_OK` checked but is never written to, because it is
|
||||
someone else's document.
|
||||
|
||||
B34's fourth assertion is the load-bearing one: `pending_uploads` and
|
||||
`pending_changes` must be **unchanged** across the refused writes. Mode bits
|
||||
alone would pass a fix that still admitted the write into `pending_op`, which is
|
||||
the actual harm — a background drain failing 403 forever.
|
||||
|
||||
**Synthetic `Shared with me/` directory contract** asserts the virtual directory
|
||||
enumerates, reports `0555`, and refuses `mkdir`/`rmdir`/`rename`. It skips when
|
||||
the directory is absent (an account with no accepted shares).
|
||||
|
||||
A regression case asserts against the *remote* outcome, so it must wait for the
|
||||
queue to drain before reading. Reading straight back through the mount proves
|
||||
nothing: the local attribute and content caches will happily return the bytes
|
||||
@@ -149,6 +174,10 @@ During development, `PDFS_ACCEPTANCE_ONLY` runs tests whose descriptive name
|
||||
contains the supplied text (for example `PDFS_ACCEPTANCE_ONLY=namespace`). A
|
||||
release acceptance run must leave it unset so the complete contract executes.
|
||||
|
||||
A filter naming a live-only case (every `regression B<n>` is one) selects nothing
|
||||
in the account-free reference run, which is not an error — that target simply has
|
||||
nothing to do. Only a filter matching *no case at all* fails, as the typo it is.
|
||||
|
||||
Passing more paths runs the same filesystem suite independently against each.
|
||||
The first path must be the FUSE mount under test; later paths may be another
|
||||
FUSE mount or a normal local mirror filesystem:
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
# Build + install: cd packaging && makepkg -fi
|
||||
|
||||
pkgname=proton-drive-linux
|
||||
pkgver=1.1.0
|
||||
pkgver=1.4.0
|
||||
pkgrel=1
|
||||
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
||||
arch=('x86_64')
|
||||
|
||||
+218
-4
@@ -1075,6 +1075,208 @@ def test_regression_b74_rename_after_close(ctx: Context) -> None:
|
||||
ctx.record("digest", hashlib.sha256(read(final)).hexdigest())
|
||||
|
||||
|
||||
SHARED_DIR_NAME = "Shared with me"
|
||||
|
||||
|
||||
def _locations(daemon: Daemon) -> list[dict]:
|
||||
"""Every local Proton Drive location the daemon reports."""
|
||||
return json.loads(daemon.command("locations", json_output=True)).get("items", [])
|
||||
|
||||
|
||||
def _shared_with_me(daemon: Daemon) -> list[dict]:
|
||||
return json.loads(daemon.command("shared-with-me", json_output=True)).get("entries", [])
|
||||
|
||||
|
||||
def _shared_root(daemon: Daemon, mount: Path, role: str) -> Path:
|
||||
"""The local path of an accepted share held at `role`.
|
||||
|
||||
Skips rather than fails when the account has no such share: the role cases
|
||||
need a *second* account to have shared something at that role, which most
|
||||
runs will not have (mount-architecture.md §7).
|
||||
"""
|
||||
entries = [e for e in _shared_with_me(daemon) if e.get("role") == role]
|
||||
if not entries:
|
||||
raise Skip(f"no accepted {role}-role share on this account")
|
||||
for entry in entries:
|
||||
# `path` is mount-relative and filled in only once the synthetic
|
||||
# directory has interned the node, so list it first.
|
||||
with contextlib.suppress(OSError):
|
||||
os.listdir(mount / SHARED_DIR_NAME)
|
||||
relative = entry.get("path") or ""
|
||||
if relative:
|
||||
candidate = mount / relative
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
raise Skip(f"{role}-role share is not resident under {SHARED_DIR_NAME}/")
|
||||
|
||||
|
||||
def test_regression_b34_viewer_share_is_read_only(ctx: Context) -> None:
|
||||
"""A viewer-role share must be read-only *and* queue nothing (B34).
|
||||
|
||||
The harm B34 names is not the mode bits: it is that a local write into a
|
||||
read-only share was accepted, entered `pending_op`, and then failed 403 in
|
||||
the drain forever. So step 4 — the queue depth being unchanged across the
|
||||
attempts — is the assertion that distinguishes the fix from cosmetics.
|
||||
"""
|
||||
daemon = ctx.require_daemon()
|
||||
mount = daemon.enclosing_mount(ctx.root)
|
||||
root = _shared_root(daemon, mount, "viewer")
|
||||
|
||||
before = daemon.status().get("mount") or {}
|
||||
mode = stat.S_IMODE(os.stat(root).st_mode)
|
||||
check(mode == 0o555, f"viewer share is {mode:o}, expected 555")
|
||||
check(not os.access(root, os.W_OK), "viewer share reports itself writable")
|
||||
|
||||
target = root / "b34-should-not-exist"
|
||||
expect_errno({errno.EACCES}, lambda: os.mkdir(target), "mkdir in a viewer share")
|
||||
expect_errno(
|
||||
{errno.EACCES},
|
||||
lambda: os.open(target, os.O_CREAT | os.O_WRONLY, 0o600),
|
||||
"create in a viewer share",
|
||||
)
|
||||
for child in sorted(root.iterdir())[:1]:
|
||||
child_mode = stat.S_IMODE(os.stat(child).st_mode)
|
||||
expected = 0o555 if child.is_dir() else 0o444
|
||||
check(child_mode == expected, f"{child.name} is {child_mode:o}, expected {expected:o}")
|
||||
expect_errno(
|
||||
{errno.EACCES},
|
||||
lambda: os.open(child, os.O_WRONLY),
|
||||
"open-for-write in a viewer share",
|
||||
)
|
||||
expect_errno(
|
||||
{errno.EACCES},
|
||||
lambda: os.rename(child, child.with_name("b34-renamed")),
|
||||
"rename in a viewer share",
|
||||
)
|
||||
expect_errno(
|
||||
{errno.EACCES},
|
||||
lambda: (os.rmdir(child) if child.is_dir() else os.unlink(child)),
|
||||
"remove from a viewer share",
|
||||
)
|
||||
|
||||
after = daemon.status().get("mount") or {}
|
||||
for key in ("pending_uploads", "pending_changes"):
|
||||
check(
|
||||
before.get(key, 0) == after.get(key, 0),
|
||||
f"a refused write changed {key}: {before.get(key)} -> {after.get(key)}",
|
||||
)
|
||||
ctx.record("viewer.mode", f"{mode:o}")
|
||||
ctx.record("viewer.queue_unchanged", True)
|
||||
|
||||
|
||||
def test_regression_b34b_editor_share_still_writes(ctx: Context) -> None:
|
||||
"""The fail-open half of B34: an editor share stays writable.
|
||||
|
||||
A permission model that denies everything would pass the viewer case and
|
||||
break the feature, so this is the guard that the enforcement is scoped.
|
||||
"""
|
||||
daemon = ctx.require_daemon()
|
||||
mount = daemon.enclosing_mount(ctx.root)
|
||||
root = _shared_root(daemon, mount, "editor")
|
||||
|
||||
is_dir = root.is_dir()
|
||||
mode = stat.S_IMODE(os.stat(root).st_mode)
|
||||
expected = 0o755 if is_dir else 0o644
|
||||
check(mode == expected, f"editor share is {mode:o}, expected {expected:o}")
|
||||
check(os.access(root, os.W_OK), "editor share reports itself unwritable")
|
||||
ctx.record("editor.mode", f"{mode:o}")
|
||||
|
||||
# A shared *file* is someone else's document: proving the mode bits and the
|
||||
# kernel's own W_OK answer is as far as this case goes. Only a shared folder
|
||||
# gets a real write, and only into a file this suite created.
|
||||
if not is_dir:
|
||||
ctx.note("editor.write", "skipped: the editor share is a file")
|
||||
return
|
||||
|
||||
payload = b"b34b-editor-write\n" * 64
|
||||
target = root / "b34b-editor-write.bin"
|
||||
write_durable(target, payload)
|
||||
daemon.wait_for_queue()
|
||||
try:
|
||||
check_bytes(read(target), payload, "editor-share write did not read back")
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(target)
|
||||
daemon.wait_for_queue()
|
||||
|
||||
|
||||
def test_shared_directory_contract(ctx: Context) -> None:
|
||||
"""The synthetic `Shared with me/` directory behaves like a real directory.
|
||||
|
||||
It is a real `Node` on a reserved volume rather than a FUSE special case, so
|
||||
it must enumerate, stat and refuse mutation like any other read-only folder —
|
||||
and must not be creatable or removable by the user.
|
||||
"""
|
||||
daemon = ctx.require_daemon()
|
||||
mount = daemon.enclosing_mount(ctx.root)
|
||||
shared = mount / SHARED_DIR_NAME
|
||||
if not shared.is_dir():
|
||||
raise Skip(f"{SHARED_DIR_NAME}/ is not present in this mount")
|
||||
|
||||
mode = stat.S_IMODE(os.stat(shared).st_mode)
|
||||
check(mode == 0o555, f"{SHARED_DIR_NAME}/ is {mode:o}, expected 555")
|
||||
# Enumerating must not raise: it is the operation the whole feature exists for.
|
||||
names = sorted(os.listdir(shared))
|
||||
expect_errno(
|
||||
{errno.EACCES},
|
||||
lambda: os.mkdir(shared / "b34-synthetic-child"),
|
||||
f"mkdir inside {SHARED_DIR_NAME}/",
|
||||
)
|
||||
expect_errno(
|
||||
{errno.EACCES, errno.ENOTEMPTY, errno.EBUSY},
|
||||
lambda: os.rmdir(shared),
|
||||
f"rmdir {SHARED_DIR_NAME}/",
|
||||
)
|
||||
expect_errno(
|
||||
{errno.EACCES, errno.EEXIST},
|
||||
lambda: os.rename(shared, mount / "b34-renamed-shared"),
|
||||
f"rename {SHARED_DIR_NAME}/",
|
||||
)
|
||||
ctx.record("shared.mode", f"{mode:o}")
|
||||
ctx.note("shared.entries", len(names))
|
||||
|
||||
|
||||
def test_regression_b79_ondemand_location_accepts_writes(ctx: Context) -> None:
|
||||
"""An on-demand device folder must accept writes (B79).
|
||||
|
||||
Every mount holds its own inode space, and the queue guard intersects the
|
||||
access each one reports. The primary mount hydrates the device folder's
|
||||
nodes parentless (their device root is never a persisted node), so a
|
||||
fail-closed classification there denied every write in the *secondary*
|
||||
mount while its own state said Owner. The symptom is EACCES on a plain
|
||||
`touch` in `~/Documents` while `~/ProtonDrive` accepts one.
|
||||
"""
|
||||
daemon = ctx.require_daemon()
|
||||
locations = [
|
||||
location
|
||||
for location in _locations(daemon)
|
||||
if location.get("kind", {}).get("kind") == "device"
|
||||
and location.get("mode") == "ondemand"
|
||||
and location.get("mounted")
|
||||
and location.get("access") == "rw"
|
||||
]
|
||||
if not locations:
|
||||
raise Skip("no mounted on-demand device folder on this machine")
|
||||
|
||||
root = Path(locations[0]["local_path"])
|
||||
payload = b"b79-ondemand-write\n" * 128
|
||||
directory = root / "pdfs-b79-acceptance"
|
||||
target = directory / "write.bin"
|
||||
try:
|
||||
os.mkdir(directory)
|
||||
write_durable(target, payload)
|
||||
daemon.wait_for_queue()
|
||||
check_bytes(read(target), payload, "on-demand write did not read back")
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(target)
|
||||
with contextlib.suppress(OSError):
|
||||
os.rmdir(directory)
|
||||
with contextlib.suppress(Exception):
|
||||
daemon.wait_for_queue()
|
||||
ctx.record("b79.location", str(root))
|
||||
|
||||
|
||||
def _conflict_copies(root: Path) -> list[str]:
|
||||
return sorted(name for name in os.listdir(root) if "(sync-conflict" in name)
|
||||
|
||||
@@ -1132,6 +1334,10 @@ TESTS = [
|
||||
Case("regression B69: identical rewrite makes no conflict", test_regression_b69_identical_rewrite, (LIVE,)),
|
||||
Case("regression B70: transient download name is not sealed", test_regression_b70_transient_download_name, (LIVE,)),
|
||||
Case("regression B74: rename after close keeps its content", test_regression_b74_rename_after_close, (LIVE,)),
|
||||
Case("regression B34: a viewer share is read-only and queues nothing", test_regression_b34_viewer_share_is_read_only, (LIVE,)),
|
||||
Case("regression B34b: an editor share still writes", test_regression_b34b_editor_share_still_writes, (LIVE,)),
|
||||
Case("synthetic Shared-with-me directory contract", test_shared_directory_contract, (LIVE,)),
|
||||
Case("regression B79: an on-demand device folder accepts writes", test_regression_b79_ondemand_location_accepts_writes, (LIVE,)),
|
||||
Case("durability across a daemon restart", test_durability_across_restart, (LIVE,)),
|
||||
]
|
||||
|
||||
@@ -1181,10 +1387,18 @@ REPORT: list[Run] = []
|
||||
def select_cases(kind: str) -> list[Case]:
|
||||
selected = os.environ.get("PDFS_ACCEPTANCE_ONLY")
|
||||
cases = [case for case in TESTS if kind in case.kinds]
|
||||
if selected:
|
||||
cases = [case for case in cases if selected.lower() in case.name.lower()]
|
||||
check(bool(cases), f"PDFS_ACCEPTANCE_ONLY={selected!r} matched no tests for {kind}")
|
||||
return cases
|
||||
if not selected:
|
||||
return cases
|
||||
needle = selected.lower()
|
||||
# A filter that names a live-only case (every regression case is one) matches
|
||||
# nothing in the reference run, which is not an error — the reference target
|
||||
# simply has nothing to do. Only a filter that matches *no case at all* is a
|
||||
# typo worth failing on.
|
||||
check(
|
||||
any(needle in case.name.lower() for case in TESTS),
|
||||
f"PDFS_ACCEPTANCE_ONLY={selected!r} matched no tests",
|
||||
)
|
||||
return [case for case in cases if needle in case.name.lower()]
|
||||
|
||||
|
||||
def reap_stale_roots(parent: Path, max_age: int) -> None:
|
||||
|
||||
Reference in new issue
Block a user