mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-07 17:12:10 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2336ad9e87 | ||
|
|
d988236bfa | ||
|
|
d78ddeab8e | ||
|
|
b04cce3a23 | ||
|
|
7e8403936b | ||
|
|
866a174c7a | ||
|
|
352117e806 | ||
|
|
4b5666eacb | ||
|
|
9bbd8eb982 |
No files matched your search
@@ -13,6 +13,7 @@ jobs:
|
|||||||
build-release:
|
build-release:
|
||||||
name: Build and Release
|
name: Build and Release
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
|
environment: 'rpm signing'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -154,6 +155,26 @@ jobs:
|
|||||||
# Copy built RPM to root directory
|
# Copy built RPM to root directory
|
||||||
cp rpmbuild/RPMS/x86_64/*.rpm .
|
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
|
- name: Upload Workflow Artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
Generated
+8
-8
@@ -3518,7 +3518,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "1.2.0"
|
version = "1.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap 4.6.4",
|
"clap 4.6.4",
|
||||||
@@ -3533,7 +3533,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "1.2.0"
|
version = "1.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"directories",
|
"directories",
|
||||||
"ignore",
|
"ignore",
|
||||||
@@ -3553,7 +3553,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "1.2.0"
|
version = "1.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fuser",
|
"fuser",
|
||||||
"image",
|
"image",
|
||||||
@@ -3569,7 +3569,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "1.2.0"
|
version = "1.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3830,9 +3830,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.3.1"
|
version = "0.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a02afd5a53ecbf0e1ea18f3102e7b99e5ad61977ee0709bd214279b2376e0077"
|
checksum = "06f6b2b3aaaf90cd3eab823e3c13a4ff784693f183f7fde97f865378c4d3e0bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -3857,9 +3857,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.3.1"
|
version = "0.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "46b5f84f0c33ac7a19b0131e971e7a17f10f12d6dc6df64c3b9dda7f37aa35bb"
|
checksum = "1037a17f8951cf3ade40a8d2a8230720caf6136383eecc7d61e89636e229793e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.0",
|
"aes-gcm 0.11.0",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+3
-3
@@ -3,15 +3,15 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.2.0"
|
version = "1.3.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
rust-version = "1.96"
|
rust-version = "1.96"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.3.1" }
|
proton-sdk = { version = "0.3.2" }
|
||||||
proton-drive-rs = { version = "0.3.1" }
|
proton-drive-rs = { version = "0.3.2" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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).
|
- **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).
|
||||||
|
- **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.
|
- **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.
|
- **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.
|
- **Data-Safe Offline Writes**: Durable scratch/staging files and a transactional pending queue preserve acknowledged writes across network failures and restarts.
|
||||||
|
|||||||
+79
-18
@@ -145,6 +145,19 @@ enum Command {
|
|||||||
#[arg(long, default_value_t = 0)]
|
#[arg(long, default_value_t = 0)]
|
||||||
offset: usize,
|
offset: usize,
|
||||||
},
|
},
|
||||||
|
/// 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.
|
/// Download a photo by uid (`volume~link`) and print its cached path.
|
||||||
OpenPhoto {
|
OpenPhoto {
|
||||||
/// Photo node uid in `volume~link` form (from `pdfs photos`).
|
/// Photo node uid in `volume~link` form (from `pdfs photos`).
|
||||||
@@ -517,6 +530,8 @@ fn main() -> Result<()> {
|
|||||||
Command::Pins => cmd_pins(),
|
Command::Pins => cmd_pins(),
|
||||||
Command::Ls { path } => cmd_ls(path),
|
Command::Ls { path } => cmd_ls(path),
|
||||||
Command::Photos { limit, offset } => cmd_photos(limit, offset),
|
Command::Photos { limit, offset } => cmd_photos(limit, offset),
|
||||||
|
Command::Albums => cmd_albums(),
|
||||||
|
Command::Album { uid, limit, offset } => cmd_album(uid, limit, offset),
|
||||||
Command::OpenPhoto { uid } => cmd_open_photo(uid),
|
Command::OpenPhoto { uid } => cmd_open_photo(uid),
|
||||||
Command::Search { query, limit } => cmd_search(query, limit),
|
Command::Search { query, limit } => cmd_search(query, limit),
|
||||||
Command::Rename { path, new_name } => cmd_rename(path, new_name),
|
Command::Rename { path, new_name } => cmd_rename(path, new_name),
|
||||||
@@ -905,7 +920,15 @@ fn cmd_shared_with_me(uid: Option<String>) -> Result<()> {
|
|||||||
CtlResponse::Entries { entries } => {
|
CtlResponse::Entries { entries } => {
|
||||||
for e in entries {
|
for e in entries {
|
||||||
let kind = if e.is_dir { "d" } else { "-" };
|
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)),
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
@@ -1477,24 +1500,52 @@ fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
|
|||||||
println!("This account has no photos volume.")
|
println!("This account has no photos volume.")
|
||||||
}
|
}
|
||||||
CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."),
|
CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."),
|
||||||
CtlResponse::Photos { items, .. } => {
|
CtlResponse::Photos { items, .. } => print_photo_page(items)?,
|
||||||
// The timeline reply only carries thumbnails already in the cache, so
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
// pull the rest of the page's in one batch to print a path per photo.
|
other => bail!("unexpected response: {other:?}"),
|
||||||
let uids: Vec<String> = items.iter().map(|p| p.uid.clone()).collect();
|
}
|
||||||
let thumbs: HashMap<String, Option<String>> =
|
Ok(())
|
||||||
match control_request(CtlRequest::PhotoThumbs { uids })? {
|
}
|
||||||
CtlResponse::Thumbs { items } => {
|
|
||||||
items.into_iter().map(|t| (t.uid, t.path)).collect()
|
/// Print one page of photos as `capture-time uid thumbnail-path`.
|
||||||
}
|
///
|
||||||
_ => HashMap::new(),
|
/// 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 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)");
|
||||||
|
println!("{} {} {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 {
|
println!(
|
||||||
let thumb = thumbs
|
"{:>6} {} {}{shared}",
|
||||||
.get(&p.uid)
|
album.photo_count, album.uid, album.name
|
||||||
.and_then(|p| p.as_deref())
|
);
|
||||||
.or(p.thumb_path.as_deref())
|
|
||||||
.unwrap_or("(no thumbnail)");
|
|
||||||
println!("{} {} {thumb}", p.capture_time, p.uid);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
@@ -1503,6 +1554,16 @@ fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
|
|||||||
Ok(())
|
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<()> {
|
fn cmd_open_photo(uid: String) -> Result<()> {
|
||||||
match control_request(CtlRequest::OpenPhoto { uid })? {
|
match control_request(CtlRequest::OpenPhoto { uid })? {
|
||||||
CtlResponse::FilePath { path } => println!("{path}"),
|
CtlResponse::FilePath { path } => println!("{path}"),
|
||||||
|
|||||||
@@ -71,6 +71,20 @@ pub enum Request {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
kind: Option<PhotoKind>,
|
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
|
/// Fetch thumbnails for the given photo uids, downloading the ones not
|
||||||
/// already cached (one batched round-trip) and replying with their on-disk
|
/// 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.
|
/// paths. Keep the batch small — it is served on demand, as tiles scroll in.
|
||||||
@@ -706,6 +720,12 @@ pub struct DirEntry {
|
|||||||
/// browser.
|
/// browser.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub path: String,
|
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
|
/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
|
||||||
@@ -946,6 +966,29 @@ pub struct PhotoMonth {
|
|||||||
pub count: usize,
|
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.
|
/// One photo in a [`Request::PhotosTimeline`] page.
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct PhotoItem {
|
pub struct PhotoItem {
|
||||||
@@ -1101,6 +1144,13 @@ pub enum Response {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
counts: Option<(usize, usize, usize)>,
|
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`]),
|
/// The months the timeline spans (reply to [`Request::PhotoMonths`]),
|
||||||
/// newest first.
|
/// newest first.
|
||||||
PhotoMonths { months: Vec<PhotoMonth> },
|
PhotoMonths { months: Vec<PhotoMonth> },
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
//! 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)?),
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.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)?),
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ use super::Db;
|
|||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
/// Current schema version. Bump on every forward migration added below.
|
/// Current schema version. Bump on every forward migration added below.
|
||||||
pub(super) const SCHEMA_VERSION: i64 = 18;
|
pub(super) const SCHEMA_VERSION: i64 = 19;
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub(super) fn migrate(&self) -> Result<()> {
|
pub(super) fn migrate(&self) -> Result<()> {
|
||||||
@@ -95,6 +95,9 @@ impl Db {
|
|||||||
if current < 18 {
|
if current < 18 {
|
||||||
tx.execute_batch(MIGRATION_V18)?;
|
tx.execute_batch(MIGRATION_V18)?;
|
||||||
}
|
}
|
||||||
|
if current < 19 {
|
||||||
|
tx.execute_batch(MIGRATION_V19)?;
|
||||||
|
}
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
@@ -472,3 +475,39 @@ BEGIN
|
|||||||
VALUES ('device', NEW.id);
|
VALUES ('device', NEW.id);
|
||||||
END;
|
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);
|
||||||
|
";
|
||||||
@@ -22,6 +22,7 @@ use crate::Result;
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
mod activity;
|
mod activity;
|
||||||
|
mod albums;
|
||||||
mod cache;
|
mod cache;
|
||||||
mod devices;
|
mod devices;
|
||||||
mod local;
|
mod local;
|
||||||
@@ -38,6 +39,7 @@ mod sync;
|
|||||||
mod trash;
|
mod trash;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
|
pub use albums::StoredAlbum;
|
||||||
pub use cache::CacheEntryInput;
|
pub use cache::CacheEntryInput;
|
||||||
pub use devices::StoredDevice;
|
pub use devices::StoredDevice;
|
||||||
pub use local::LocalFileHit;
|
pub use local::LocalFileHit;
|
||||||
|
|||||||
@@ -94,4 +94,26 @@ impl Db {
|
|||||||
conn.execute("DELETE FROM sync_state WHERE key = ?1", params![key])?;
|
conn.execute("DELETE FROM sync_state WHERE key = ?1", params![key])?;
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1334,6 +1334,8 @@ fn migration_v17_adds_share_access_to_a_v16_fixture() {
|
|||||||
"DROP TRIGGER mount_sync_folder_insert;
|
"DROP TRIGGER mount_sync_folder_insert;
|
||||||
DROP TABLE mount;
|
DROP TABLE mount;
|
||||||
DROP TABLE share_access;
|
DROP TABLE share_access;
|
||||||
|
DROP TABLE album_photos;
|
||||||
|
DROP TABLE albums;
|
||||||
UPDATE sync_state SET value = '16' WHERE key = 'schema_version';",
|
UPDATE sync_state SET value = '16' WHERE key = 'schema_version';",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1347,9 +1349,10 @@ fn migration_v17_adds_share_access_to_a_v16_fixture() {
|
|||||||
let root = uid("offline-share");
|
let root = uid("offline-share");
|
||||||
db.set_share_access(&root, crate::Access::Viewer).unwrap();
|
db.set_share_access(&root, crate::Access::Viewer).unwrap();
|
||||||
assert_eq!(db.share_access(&root).unwrap(), Some(crate::Access::Viewer));
|
assert_eq!(db.share_access(&root).unwrap(), Some(crate::Access::Viewer));
|
||||||
|
// Migrating an old fixture always lands on head, whatever head is today.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
db.state_str("schema_version").unwrap().as_deref(),
|
db.state_str("schema_version").unwrap().as_deref(),
|
||||||
Some("18")
|
Some(SCHEMA_VERSION.to_string().as_str())
|
||||||
);
|
);
|
||||||
|
|
||||||
drop(db);
|
drop(db);
|
||||||
@@ -1438,9 +1441,10 @@ fn migration_v18_projects_v17_sync_folders_and_cascades_deletes() {
|
|||||||
1,
|
1,
|
||||||
"the V18 foreign key must remove the device projection"
|
"the V18 foreign key must remove the device projection"
|
||||||
);
|
);
|
||||||
|
// Migrating an old fixture always lands on head, whatever head is today.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
db.state_str("schema_version").unwrap().as_deref(),
|
db.state_str("schema_version").unwrap().as_deref(),
|
||||||
Some("18")
|
Some(SCHEMA_VERSION.to_string().as_str())
|
||||||
);
|
);
|
||||||
|
|
||||||
drop(db);
|
drop(db);
|
||||||
@@ -2842,6 +2846,8 @@ fn test_db_has_children_and_trashed_filtering() {
|
|||||||
is_shared_publicly: false,
|
is_shared_publicly: false,
|
||||||
signature_email: None,
|
signature_email: None,
|
||||||
membership: None,
|
membership: None,
|
||||||
|
photo: None,
|
||||||
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
};
|
};
|
||||||
db.upsert_node(&parent_node).unwrap();
|
db.upsert_node(&parent_node).unwrap();
|
||||||
@@ -2866,6 +2872,8 @@ fn test_db_has_children_and_trashed_filtering() {
|
|||||||
is_shared_publicly: false,
|
is_shared_publicly: false,
|
||||||
signature_email: None,
|
signature_email: None,
|
||||||
membership: None,
|
membership: None,
|
||||||
|
photo: None,
|
||||||
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
};
|
};
|
||||||
db.upsert_node(&child_node).unwrap();
|
db.upsert_node(&child_node).unwrap();
|
||||||
@@ -2909,3 +2917,136 @@ fn test_db_has_create_op() {
|
|||||||
db.delete_ops_for_uid(local_uid).unwrap();
|
db.delete_ops_for_uid(local_uid).unwrap();
|
||||||
assert!(!db.has_create_op(local_uid).unwrap(), "op deleted");
|
assert!(!db.has_create_op(local_uid).unwrap(), "op deleted");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The album listing is a wholesale replacement, and an album that leaves the
|
||||||
|
/// listing must take its contents with it — nothing else ever deletes those rows.
|
||||||
|
#[test]
|
||||||
|
fn albums_replace_drops_the_contents_of_an_album_that_left() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let album = |uid: &str, name: &str, activity: Option<i64>| StoredAlbum {
|
||||||
|
uid: uid.into(),
|
||||||
|
name: name.into(),
|
||||||
|
photo_count: 2,
|
||||||
|
cover_uid: Some("cover".into()),
|
||||||
|
last_activity: activity,
|
||||||
|
shared: false,
|
||||||
|
};
|
||||||
|
db.albums_replace(&[
|
||||||
|
album("a1", "Trip", Some(500)),
|
||||||
|
album("a2", "Cats", Some(400)),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
db.album_photos_replace("a1", &[("p1".into(), 300, None, None)])
|
||||||
|
.unwrap();
|
||||||
|
db.album_photos_replace("a2", &[("p2".into(), 200, None, None)])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// The next refresh no longer has a2 — it was deleted, or unshared.
|
||||||
|
db.albums_replace(&[album("a1", "Trip", Some(600))])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(db.albums_count().unwrap(), 1);
|
||||||
|
assert_eq!(db.album_photos_count("a1").unwrap(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
db.album_photos_count("a2").unwrap(),
|
||||||
|
0,
|
||||||
|
"a departed album's photos go with it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An album's contents are replaced like the timeline: server order is kept, and
|
||||||
|
/// what a thumbnail attempt cost a download to learn survives the refresh.
|
||||||
|
#[test]
|
||||||
|
fn album_photos_replace_keeps_what_was_learned() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.albums_replace(&[StoredAlbum {
|
||||||
|
uid: "a1".into(),
|
||||||
|
name: "Trip".into(),
|
||||||
|
photo_count: 3,
|
||||||
|
cover_uid: None,
|
||||||
|
last_activity: None,
|
||||||
|
shared: true,
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
db.album_photos_replace(
|
||||||
|
"a1",
|
||||||
|
&[
|
||||||
|
("p1".into(), 300, None, Some("video/mp4".into())),
|
||||||
|
("p2".into(), 200, None, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
db.album_photo_set_thumb("p1", THUMB_HAVE, Some(1.5))
|
||||||
|
.unwrap();
|
||||||
|
db.album_photo_set_thumb("p2", THUMB_NONE, None).unwrap();
|
||||||
|
|
||||||
|
db.album_photos_replace(
|
||||||
|
"a1",
|
||||||
|
&[
|
||||||
|
("p0".into(), 400, Some("new.jpg".into()), None),
|
||||||
|
("p1".into(), 300, None, None),
|
||||||
|
("p2".into(), 200, None, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let page = db.album_photos_page("a1", 0, 10).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
|
["p0", "p1", "p2"],
|
||||||
|
"the album's own order is preserved"
|
||||||
|
);
|
||||||
|
assert_eq!(page[1].ratio, Some(1.5));
|
||||||
|
assert_eq!(page[1].thumb_state, THUMB_HAVE);
|
||||||
|
assert_eq!(page[2].thumb_state, THUMB_NONE);
|
||||||
|
// The media type is learned-and-kept, so p1 stays classified as a video even
|
||||||
|
// though the second refresh carried none.
|
||||||
|
assert_eq!(page[1].kind, crate::control::PhotoKind::Video);
|
||||||
|
assert_eq!(page[0].thumb_state, THUMB_UNKNOWN);
|
||||||
|
|
||||||
|
// Paging is relative to the album, and the shared flag round-trips.
|
||||||
|
let second = db.album_photos_page("a1", 1, 1).unwrap();
|
||||||
|
assert_eq!(second.len(), 1);
|
||||||
|
assert_eq!(second[0].uid, "p1");
|
||||||
|
assert!(db.albums_list().unwrap()[0].shared);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A photo held only by an album — everything in an album shared with us — is
|
||||||
|
/// still resolvable for its capture time, which is what tags its cached
|
||||||
|
/// thumbnail. One row per uid, even when the photo is in several albums.
|
||||||
|
#[test]
|
||||||
|
fn album_photos_by_uid_resolves_a_photo_outside_the_timeline() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.album_photos_replace("a1", &[("p1".into(), 300, None, None)])
|
||||||
|
.unwrap();
|
||||||
|
db.album_photos_replace("a2", &[("p1".into(), 300, None, None)])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let found = db
|
||||||
|
.album_photos_by_uid(&["p1".into(), "absent".into()])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(found.len(), 1, "a photo in two albums resolves once");
|
||||||
|
assert_eq!(found[0].capture_time, 300);
|
||||||
|
assert!(db.album_photos_by_uid(&[]).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-album freshness stamps are keyed by uid, so invalidating them all is a
|
||||||
|
/// prefix sweep — and it must not take unrelated keys with it.
|
||||||
|
#[test]
|
||||||
|
fn clear_state_prefix_takes_only_the_prefixed_keys() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.set_state_i64("album_synced_ms:vol~a1", 1).unwrap();
|
||||||
|
db.set_state_i64("album_synced_ms:vol~a2", 2).unwrap();
|
||||||
|
db.set_state_i64("albums_synced_ms", 3).unwrap();
|
||||||
|
|
||||||
|
db.clear_state_prefix("album_synced_ms:").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(db.state_i64("album_synced_ms:vol~a1").unwrap(), None);
|
||||||
|
assert_eq!(db.state_i64("album_synced_ms:vol~a2").unwrap(), None);
|
||||||
|
assert_eq!(
|
||||||
|
db.state_i64("albums_synced_ms").unwrap(),
|
||||||
|
Some(3),
|
||||||
|
"the listing's own stamp is not a per-album one"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,9 +19,18 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::mounts::MountMode;
|
use crate::mounts::MountMode;
|
||||||
|
|
||||||
/// File name of the profile inside the device root folder.
|
/// File name of the profile.
|
||||||
pub const PROFILE_FILE_NAME: &str = "profile.json";
|
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.
|
/// Format version of the profile document.
|
||||||
///
|
///
|
||||||
/// Bumped only for changes a *reader* cannot survive. Adding fields does not
|
/// Bumped only for changes a *reader* cannot survive. Adding fields does not
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -545,6 +545,8 @@ mod tests {
|
|||||||
is_shared_publicly: false,
|
is_shared_publicly: false,
|
||||||
signature_email: None,
|
signature_email: None,
|
||||||
membership: None,
|
membership: None,
|
||||||
|
photo: None,
|
||||||
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,6 +185,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
}
|
}
|
||||||
RefreshScope::Photos => {
|
RefreshScope::Photos => {
|
||||||
core.invalidate_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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -213,6 +216,31 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
|
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(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) {
|
||||||
Ok(months) => CtlResponse::PhotoMonths {
|
Ok(months) => CtlResponse::PhotoMonths {
|
||||||
months: months
|
months: months
|
||||||
|
|||||||
@@ -383,6 +383,15 @@ impl Core {
|
|||||||
})?
|
})?
|
||||||
.to_string();
|
.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 device = self.ensure_device()?;
|
||||||
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
|
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
|
||||||
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
||||||
|
|||||||
@@ -1143,6 +1143,8 @@ mod tests {
|
|||||||
is_shared_publicly: false,
|
is_shared_publicly: false,
|
||||||
signature_email: None,
|
signature_email: None,
|
||||||
membership: None,
|
membership: None,
|
||||||
|
photo: None,
|
||||||
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1291,6 +1293,8 @@ mod tests {
|
|||||||
is_shared_publicly: false,
|
is_shared_publicly: false,
|
||||||
signature_email: None,
|
signature_email: None,
|
||||||
membership: None,
|
membership: None,
|
||||||
|
photo: None,
|
||||||
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -992,7 +992,15 @@ impl ProtonFs {
|
|||||||
warn!(uid = %root.uid, error = %e, "db upsert root failed");
|
warn!(uid = %root.uid, error = %e, "db upsert root failed");
|
||||||
}
|
}
|
||||||
st.by_uid.insert(root.uid.clone(), ROOT_INO);
|
st.by_uid.insert(root.uid.clone(), ROOT_INO);
|
||||||
let access = st.access_for_node(ROOT_INO, &root);
|
// 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(
|
st.entries.insert(
|
||||||
ROOT_INO,
|
ROOT_INO,
|
||||||
Entry {
|
Entry {
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ use proton_drive_rs::{
|
|||||||
ProtonPhotosClient, RevisionReader, SharedWithMeItem, ThumbnailType,
|
ProtonPhotosClient, RevisionReader, SharedWithMeItem, ThumbnailType,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mod albums;
|
||||||
mod background;
|
mod background;
|
||||||
mod control;
|
mod control;
|
||||||
use control::run_control_socket;
|
use control::run_control_socket;
|
||||||
@@ -184,6 +185,11 @@ const TRASH_FIRST_WAIT: Duration = Duration::from_secs(20);
|
|||||||
/// doesn't re-ask the server on every page request).
|
/// doesn't re-ask the server on every page request).
|
||||||
const PHOTOS_SYNCED_MS: &str = "photos_synced_ms";
|
const PHOTOS_SYNCED_MS: &str = "photos_synced_ms";
|
||||||
const PHOTOS_AVAILABLE: &str = "photos_available";
|
const PHOTOS_AVAILABLE: &str = "photos_available";
|
||||||
|
/// Freshness stamp of the album listing, and of one album's contents (suffixed
|
||||||
|
/// with the album uid). Separate from the timeline's: albums are enumerated by
|
||||||
|
/// their own endpoints, and an album is only fetched when it is opened.
|
||||||
|
const ALBUMS_SYNCED_MS: &str = "albums_synced_ms";
|
||||||
|
const ALBUM_SYNCED_PREFIX: &str = "album_synced_ms:";
|
||||||
const TRASH_SYNCED_MS: &str = "trash_synced_ms";
|
const TRASH_SYNCED_MS: &str = "trash_synced_ms";
|
||||||
const SHARED_WITH_ME_NAME: &str = "shared_with_me_name";
|
const SHARED_WITH_ME_NAME: &str = "shared_with_me_name";
|
||||||
const SHARED_WITH_ME_SYNCED_MS: &str = "shared_with_me_synced_ms";
|
const SHARED_WITH_ME_SYNCED_MS: &str = "shared_with_me_synced_ms";
|
||||||
@@ -343,6 +349,8 @@ struct Core {
|
|||||||
/// already running, so a burst of page requests against a stale listing kicks
|
/// already running, so a burst of page requests against a stale listing kicks
|
||||||
/// off one refresh rather than one per request.
|
/// off one refresh rather than one per request.
|
||||||
timeline_refreshing: Arc<AtomicBool>,
|
timeline_refreshing: Arc<AtomicBool>,
|
||||||
|
/// The same, for the album listing.
|
||||||
|
albums_refreshing: Arc<AtomicBool>,
|
||||||
trash_refreshing: Arc<AtomicBool>,
|
trash_refreshing: Arc<AtomicBool>,
|
||||||
/// Fires whenever a trash refresh persists a batch or finishes, so a
|
/// Fires whenever a trash refresh persists a batch or finishes, so a
|
||||||
/// `ListTrash` request waiting on a first-ever refresh wakes on progress
|
/// `ListTrash` request waiting on a first-ever refresh wakes on progress
|
||||||
@@ -2956,6 +2964,9 @@ impl Core {
|
|||||||
// Listing entries live in the requested dir; the caller derives
|
// Listing entries live in the requested dir; the caller derives
|
||||||
// the path from its name. Left empty.
|
// the path from its name. Left empty.
|
||||||
path: String::new(),
|
path: String::new(),
|
||||||
|
// My own tree: a role only means something under a share, and
|
||||||
|
// this listing never crosses into one.
|
||||||
|
role: String::new(),
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -3415,6 +3426,7 @@ impl Core {
|
|||||||
cached: false,
|
cached: false,
|
||||||
uid: item.uid,
|
uid: item.uid,
|
||||||
path: String::new(),
|
path: String::new(),
|
||||||
|
role: String::new(),
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -4036,6 +4048,9 @@ fn local_node(uid: NodeUid, parent_uid: NodeUid, name: String, is_dir: bool) ->
|
|||||||
is_shared_publicly: false,
|
is_shared_publicly: false,
|
||||||
signature_email: None,
|
signature_email: None,
|
||||||
membership: None,
|
membership: None,
|
||||||
|
// A locally fabricated node is on the main volume, never a photo/album.
|
||||||
|
photo: None,
|
||||||
|
album: None,
|
||||||
// Nothing signed it: it has never been near the crypto layer.
|
// Nothing signed it: it has never been near the crypto layer.
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
}
|
}
|
||||||
@@ -4624,6 +4639,8 @@ mod pending_size_tests {
|
|||||||
is_shared_publicly: false,
|
is_shared_publicly: false,
|
||||||
signature_email: None,
|
signature_email: None,
|
||||||
membership: None,
|
membership: None,
|
||||||
|
photo: None,
|
||||||
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6166,6 +6183,8 @@ mod tests {
|
|||||||
is_shared_publicly: false,
|
is_shared_publicly: false,
|
||||||
signature_email: None,
|
signature_email: None,
|
||||||
membership: None,
|
membership: None,
|
||||||
|
photo: None,
|
||||||
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -376,6 +376,7 @@ pub fn mount(
|
|||||||
hidden: Arc::new(Mutex::new(HashSet::new())),
|
hidden: Arc::new(Mutex::new(HashSet::new())),
|
||||||
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
||||||
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
|
albums_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
trash_refreshing: Arc::new(AtomicBool::new(false)),
|
trash_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
trash_progress: Arc::new(tokio::sync::Notify::new()),
|
trash_progress: Arc::new(tokio::sync::Notify::new()),
|
||||||
conflict_notified: Arc::new(Mutex::new(HashSet::new())),
|
conflict_notified: Arc::new(Mutex::new(HashSet::new())),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! user. Thumbnails the server has none for (anything a camera wrote rather than
|
//! 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.
|
//! 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 std::path::PathBuf;
|
||||||
|
|
||||||
use pdfs_core::control::{PhotoItem, PhotoKind, PhotoThumb};
|
use pdfs_core::control::{PhotoItem, PhotoKind, PhotoThumb};
|
||||||
@@ -154,7 +154,20 @@ impl Core {
|
|||||||
pub(crate) fn photo_thumbs(&self, uids: &[NodeUid]) -> Vec<PhotoThumb> {
|
pub(crate) fn photo_thumbs(&self, uids: &[NodeUid]) -> Vec<PhotoThumb> {
|
||||||
let ttype = ThumbnailType::Thumbnail.as_i32();
|
let ttype = ThumbnailType::Thumbnail.as_i32();
|
||||||
let keys: Vec<String> = uids.iter().map(|u| u.to_string()).collect();
|
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
|
let tags: HashMap<String, i64> = stored
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| (p.uid.clone(), p.capture_time))
|
.map(|p| (p.uid.clone(), p.capture_time))
|
||||||
@@ -329,11 +342,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>) {
|
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");
|
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
|
/// Whether the listing stamped under `key` is older than `ttl` (or was never
|
||||||
|
|||||||
+137
-14
@@ -13,7 +13,9 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||||||
|
|
||||||
use pdfs_core::control::RestorableFolder;
|
use pdfs_core::control::RestorableFolder;
|
||||||
use pdfs_core::mounts::MountMode;
|
use pdfs_core::mounts::MountMode;
|
||||||
use pdfs_core::profile::{PROFILE_FILE_NAME, PROFILE_VERSION, Profile, ProfileFolder, ProfilePin};
|
use pdfs_core::profile::{
|
||||||
|
PROFILE_DIR_NAME, PROFILE_FILE_NAME, PROFILE_VERSION, Profile, ProfileFolder, ProfilePin,
|
||||||
|
};
|
||||||
use pdfs_core::{CoreError, CoreResult};
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
@@ -64,8 +66,9 @@ impl Core {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize this machine's arrangement and upload it to `profile.json` in
|
/// Serialize this machine's arrangement and upload it to `profile.json`
|
||||||
/// the device root, replacing any existing one.
|
/// inside the device's `.proton-drive-linux` folder, replacing any existing
|
||||||
|
/// one.
|
||||||
///
|
///
|
||||||
/// Does nothing when no device is registered: a machine that has never
|
/// 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
|
/// synced a folder has nothing to describe, and registering a device just to
|
||||||
@@ -86,11 +89,15 @@ impl Core {
|
|||||||
let bytes = profile.to_bytes().map_err(CoreError::internal)?;
|
let bytes = profile.to_bytes().map_err(CoreError::internal)?;
|
||||||
let len = bytes.len() as i64;
|
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
|
// 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
|
// same name: the profile is a single mutable record, and a folder with
|
||||||
// with `profile.json` and `profile (1).json` in it teaches the restore
|
// `profile.json` and `profile (1).json` in it teaches the restore
|
||||||
// nothing about which to trust.
|
// 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 {
|
match existing {
|
||||||
Some(uid) => self
|
Some(uid) => self
|
||||||
.rt
|
.rt
|
||||||
@@ -106,7 +113,7 @@ impl Core {
|
|||||||
None => {
|
None => {
|
||||||
self.rt
|
self.rt
|
||||||
.block_on(self.client.upload_file_replacing_draft_from(
|
.block_on(self.client.upload_file_replacing_draft_from(
|
||||||
&root_uid,
|
&dir_uid,
|
||||||
PROFILE_FILE_NAME,
|
PROFILE_FILE_NAME,
|
||||||
"application/json",
|
"application/json",
|
||||||
Cursor::new(bytes),
|
Cursor::new(bytes),
|
||||||
@@ -164,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
|
/// The sibling of `find_device_child_folder`; kept separate because a folder
|
||||||
/// named `profile.json` must not be mistaken for the profile.
|
/// named `profile.json` must not be mistaken for the profile.
|
||||||
fn find_device_child_file(
|
fn find_device_child_file(
|
||||||
&self,
|
&self,
|
||||||
root_uid: &NodeUid,
|
parent_uid: &NodeUid,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> CoreResult<Option<NodeUid>> {
|
) -> CoreResult<Option<NodeUid>> {
|
||||||
let uids = self
|
let uids = self
|
||||||
.rt
|
.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"))?;
|
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
||||||
if uids.is_empty() {
|
if uids.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -190,10 +197,47 @@ impl Core {
|
|||||||
.map(|n| n.uid))
|
.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>> {
|
fn load_profile(&self, root_uid: &NodeUid) -> CoreResult<Option<Profile>> {
|
||||||
let Some(uid) = self.find_device_child_file(root_uid, PROFILE_FILE_NAME)? else {
|
let current = match self.find_device_child_folder(root_uid, PROFILE_DIR_NAME)? {
|
||||||
return Ok(None);
|
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();
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
self.rt
|
self.rt
|
||||||
@@ -252,7 +296,10 @@ impl Core {
|
|||||||
|
|
||||||
Ok(nodes
|
Ok(nodes
|
||||||
.into_iter()
|
.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| {
|
.map(|n| {
|
||||||
let uid = n.uid.to_string();
|
let uid = n.uid.to_string();
|
||||||
let saved = profile
|
let saved = profile
|
||||||
@@ -402,6 +449,82 @@ mod tests {
|
|||||||
assert_eq!(restore_mode("unknown"), 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
|
/// A path whose parent exists but which does not itself is exactly the
|
||||||
/// restore case — the directory is about to be created.
|
/// restore case — the directory is about to be created.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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};
|
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
|
/// A node from a *shared* listing as a [`DirEntry`].
|
||||||
/// 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.
|
/// Still uid-addressed — that is the only handle a foreign-volume node is
|
||||||
fn shared_entry(n: Node) -> DirEntry {
|
/// 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 is_dir = n.is_folder();
|
||||||
let size = match &n.kind {
|
let size = match &n.kind {
|
||||||
NodeKind::File {
|
NodeKind::File {
|
||||||
@@ -32,6 +37,13 @@ fn shared_entry(n: Node) -> DirEntry {
|
|||||||
} => claimed_size.unwrap_or(*total_size_on_storage).max(0) as u64,
|
} => claimed_size.unwrap_or(*total_size_on_storage).max(0) as u64,
|
||||||
NodeKind::Folder => 0,
|
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 {
|
DirEntry {
|
||||||
name: n.name,
|
name: n.name,
|
||||||
is_dir,
|
is_dir,
|
||||||
@@ -39,8 +51,9 @@ fn shared_entry(n: Node) -> DirEntry {
|
|||||||
modified: n.modification_time,
|
modified: n.modification_time,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
cached: false,
|
cached: false,
|
||||||
|
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
|
||||||
uid: n.uid.to_string(),
|
uid: n.uid.to_string(),
|
||||||
path: String::new(),
|
role,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,7 +384,10 @@ impl Core {
|
|||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_nodes(&uids))
|
.block_on(self.client.enumerate_nodes(&uids))
|
||||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
.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.
|
/// List the children of a folder shared with me, addressed by uid.
|
||||||
@@ -393,7 +409,10 @@ impl Core {
|
|||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_nodes(&child_uids))
|
.block_on(self.client.enumerate_nodes(&child_uids))
|
||||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
.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
|
/// Download a file shared with me into the content cache, returning its
|
||||||
|
|||||||
@@ -247,6 +247,17 @@ impl State {
|
|||||||
self.access_changes.drain().collect()
|
self.access_changes.drain().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `uid` lives on the volume this mount is rooted in.
|
||||||
|
///
|
||||||
|
/// Used to decide how a node with no resolvable parent is classified:
|
||||||
|
/// our own volume fails open (it is owned content), a foreign volume
|
||||||
|
/// fails closed (it can only be there because of a share).
|
||||||
|
pub(crate) fn is_own_volume(&self, uid: &NodeUid) -> bool {
|
||||||
|
self.entries
|
||||||
|
.get(&crate::ROOT_INO)
|
||||||
|
.is_some_and(|root| root.uid.volume_id == uid.volume_id)
|
||||||
|
}
|
||||||
|
|
||||||
fn stored_share_access(&self, uid: &NodeUid) -> Option<Access> {
|
fn stored_share_access(&self, uid: &NodeUid) -> Option<Access> {
|
||||||
self.share_access.get(uid).copied()
|
self.share_access.get(uid).copied()
|
||||||
}
|
}
|
||||||
@@ -448,8 +459,13 @@ impl State {
|
|||||||
let roots: Vec<u64> = self
|
let roots: Vec<u64> = self
|
||||||
.entries
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|entry| {
|
.filter(|&(&ino, entry)| {
|
||||||
let (_, entry) = entry;
|
// The filesystem root (parent == self) is always owned; never
|
||||||
|
// downgrade it even if it carries membership from the owner
|
||||||
|
// sharing this folder with someone else.
|
||||||
|
if entry.parent == ino {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
entry.access != Access::Owner
|
entry.access != Access::Owner
|
||||||
|| entry.node.membership.is_some()
|
|| entry.node.membership.is_some()
|
||||||
|| self.share_access.contains_key(&entry.uid)
|
|| self.share_access.contains_key(&entry.uid)
|
||||||
@@ -494,17 +510,35 @@ impl State {
|
|||||||
return Access::Unknown;
|
return Access::Unknown;
|
||||||
}
|
}
|
||||||
let stored = state.share_access.get(&entry.uid).copied();
|
let stored = state.share_access.get(&entry.uid).copied();
|
||||||
let access = if let Some(stored) = stored {
|
// The root of this mount is always owned, regardless of any
|
||||||
stored
|
// stale share_access row that a global downgrade may have left.
|
||||||
} else if entry.parent == ino {
|
let access = if entry.parent == ino {
|
||||||
Access::Owner
|
Access::Owner
|
||||||
|
} else if let Some(stored) = stored {
|
||||||
|
stored
|
||||||
} else if !state.entries.contains_key(&entry.parent) {
|
} else if !state.entries.contains_key(&entry.parent) {
|
||||||
|
// The parent is not resident, so there is nothing to inherit
|
||||||
|
// from. This is the normal shape of a device-folder root: its
|
||||||
|
// parent is the device root, which is never persisted as a
|
||||||
|
// node, so every on-demand folder hydrates parentless here.
|
||||||
|
// Answer the way the persisted authority (`effective_node_access`)
|
||||||
|
// does — a recorded share row above it if we have one, otherwise
|
||||||
|
// fail open on our own volume and closed on a foreign one.
|
||||||
|
// Failing closed on our own volume denies every write in every
|
||||||
|
// secondary mount, because `require_uid_writable` intersects
|
||||||
|
// this state with the mount that actually owns the node.
|
||||||
entry
|
entry
|
||||||
.node
|
.node
|
||||||
.parent_uid
|
.parent_uid
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|parent_uid| state.share_access.get(parent_uid).copied())
|
.and_then(|parent_uid| state.share_access.get(parent_uid).copied())
|
||||||
.unwrap_or(Access::Unknown)
|
.unwrap_or_else(|| {
|
||||||
|
if state.is_own_volume(&entry.uid) {
|
||||||
|
Access::Owner
|
||||||
|
} else {
|
||||||
|
Access::Unknown
|
||||||
|
}
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
let parent_access = resolve(state, entry.parent, memo, visiting);
|
let parent_access = resolve(state, entry.parent, memo, visiting);
|
||||||
if entry.node.membership.is_none() || parent_access != Access::Owner {
|
if entry.node.membership.is_none() || parent_access != Access::Owner {
|
||||||
@@ -538,8 +572,12 @@ impl State {
|
|||||||
}
|
}
|
||||||
let discovered: Vec<(NodeUid, Access)> = self
|
let discovered: Vec<(NodeUid, Access)> = self
|
||||||
.entries
|
.entries
|
||||||
.values()
|
.iter()
|
||||||
.filter_map(|entry| {
|
.filter_map(|(&ino, entry)| {
|
||||||
|
// The filesystem root is owned, not a discovered share root.
|
||||||
|
if entry.parent == ino {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let parent_access = self
|
let parent_access = self
|
||||||
.entries
|
.entries
|
||||||
.get(&entry.parent)
|
.get(&entry.parent)
|
||||||
@@ -928,6 +966,8 @@ mod tests {
|
|||||||
is_shared_publicly: false,
|
is_shared_publicly: false,
|
||||||
signature_email: None,
|
signature_email: None,
|
||||||
membership: None,
|
membership: None,
|
||||||
|
photo: None,
|
||||||
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1250,6 +1290,128 @@ mod tests {
|
|||||||
assert!(st.require_writable(owned_root).is_ok());
|
assert!(st.require_writable(owned_root).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Materialize `nodes` the way `Core::hydrate` does: allocate every inode
|
||||||
|
/// first, then insert entries with `Access::Unknown`, resolving each parent
|
||||||
|
/// by uid and parking a node whose parent never made it to disk at
|
||||||
|
/// `ORPHAN_INO`. `nodes[0]` becomes the mount root.
|
||||||
|
fn hydrate_from(st: &mut State, nodes: &[Node]) -> Vec<u64> {
|
||||||
|
for node in nodes {
|
||||||
|
st.db.upsert_node(node).unwrap();
|
||||||
|
}
|
||||||
|
let inos: Vec<u64> = nodes
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, node)| {
|
||||||
|
let ino = crate::ROOT_INO + i as u64;
|
||||||
|
st.by_uid.insert(node.uid.clone(), ino);
|
||||||
|
ino
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
st.next_ino = crate::ROOT_INO + nodes.len() as u64;
|
||||||
|
// Reverse order so entries are inserted before their parents, matching
|
||||||
|
// the hydration path's non-topological materialization.
|
||||||
|
for (i, node) in nodes.iter().enumerate().rev() {
|
||||||
|
let ino = inos[i];
|
||||||
|
let parent = if ino == crate::ROOT_INO {
|
||||||
|
crate::ROOT_INO
|
||||||
|
} else {
|
||||||
|
node.parent_uid
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| st.by_uid.get(p).copied())
|
||||||
|
.unwrap_or(crate::ORPHAN_INO)
|
||||||
|
};
|
||||||
|
st.entries.insert(
|
||||||
|
ino,
|
||||||
|
Entry {
|
||||||
|
uid: node.uid.clone(),
|
||||||
|
parent,
|
||||||
|
node: node.clone(),
|
||||||
|
access: Access::Unknown,
|
||||||
|
lookup_count: 1,
|
||||||
|
open_count: 0,
|
||||||
|
unlinked: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
st.hydrate_access();
|
||||||
|
inos
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A device folder's parent is the device root, which is never persisted as
|
||||||
|
/// a node, so the folder hydrates with no resident parent. Classifying that
|
||||||
|
/// as `Unknown` denied every write in every on-demand mount: the mount's own
|
||||||
|
/// fork says `Owner`, but `require_uid_writable` intersects it with this
|
||||||
|
/// state, and the intersection is what reaches the queue guards.
|
||||||
|
#[test]
|
||||||
|
fn own_volume_nodes_without_a_resident_parent_stay_owned() {
|
||||||
|
let (mut st, _dir) = state();
|
||||||
|
let nodes = [
|
||||||
|
node("root", "none", "My Files", true),
|
||||||
|
// `device-root` is deliberately absent from `nodes`.
|
||||||
|
node("documents", "device-root", "Documents", true),
|
||||||
|
node("folder", "documents", "folder", true),
|
||||||
|
node("leaf", "folder", "leaf.txt", false),
|
||||||
|
];
|
||||||
|
let inos = hydrate_from(&mut st, &nodes);
|
||||||
|
|
||||||
|
for ino in inos {
|
||||||
|
assert_eq!(st.entries[&ino].access, Access::Owner);
|
||||||
|
assert!(st.require_writable(ino).is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same shape on a foreign volume can only be there because of a share,
|
||||||
|
/// so it keeps failing closed.
|
||||||
|
#[test]
|
||||||
|
fn foreign_volume_nodes_without_a_resident_parent_fail_closed() {
|
||||||
|
let (mut st, _dir) = state();
|
||||||
|
let foreign = |link: &str, parent: &str, name: &str, is_dir: bool| {
|
||||||
|
let mut node = node(link, parent, name, is_dir);
|
||||||
|
node.uid = NodeUid::new(VolumeId::from("other-vol"), LinkId::from(link));
|
||||||
|
node.parent_uid = Some(NodeUid::new(
|
||||||
|
VolumeId::from("other-vol"),
|
||||||
|
LinkId::from(parent),
|
||||||
|
));
|
||||||
|
node
|
||||||
|
};
|
||||||
|
let nodes = [
|
||||||
|
node("root", "none", "My Files", true),
|
||||||
|
// `their-share` is absent, so the subtree has no resident parent.
|
||||||
|
foreign("their-folder", "their-share", "Team Budget", true),
|
||||||
|
foreign("their-leaf", "their-folder", "budget.ods", false),
|
||||||
|
];
|
||||||
|
let inos = hydrate_from(&mut st, &nodes);
|
||||||
|
|
||||||
|
assert_eq!(st.entries[&inos[0]].access, Access::Owner);
|
||||||
|
for ino in &inos[1..] {
|
||||||
|
assert_eq!(st.entries[ino].access, Access::Unknown);
|
||||||
|
assert_eq!(st.require_writable(*ino).unwrap_err().code(), libc::EACCES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fail-open is only for the absence of a recorded authority: a share row on
|
||||||
|
/// the missing parent still governs the subtree below it.
|
||||||
|
#[test]
|
||||||
|
fn persisted_authority_outranks_the_own_volume_fail_open() {
|
||||||
|
let (mut st, _dir) = state();
|
||||||
|
st.db
|
||||||
|
.set_share_access(&uid("device-root"), Access::Viewer)
|
||||||
|
.unwrap();
|
||||||
|
st.share_access = st.db.all_share_access().unwrap();
|
||||||
|
let nodes = [
|
||||||
|
node("root", "none", "My Files", true),
|
||||||
|
node("documents", "device-root", "Documents", true),
|
||||||
|
node("leaf", "documents", "leaf.txt", false),
|
||||||
|
];
|
||||||
|
let inos = hydrate_from(&mut st, &nodes);
|
||||||
|
|
||||||
|
assert_eq!(st.entries[&inos[0]].access, Access::Owner);
|
||||||
|
for ino in &inos[1..] {
|
||||||
|
assert_eq!(st.entries[ino].access, Access::Viewer);
|
||||||
|
assert_eq!(st.require_writable(*ino).unwrap_err().code(), libc::EACCES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reinterned_share_root_propagates_a_viewer_downgrade() {
|
fn reinterned_share_root_propagates_a_viewer_downgrade() {
|
||||||
let (mut st, _dir) = state();
|
let (mut st, _dir) = state();
|
||||||
|
|||||||
@@ -354,6 +354,8 @@ mod tests {
|
|||||||
is_shared_publicly: false,
|
is_shared_publicly: false,
|
||||||
signature_email: None,
|
signature_email: None,
|
||||||
membership: None,
|
membership: None,
|
||||||
|
photo: None,
|
||||||
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ pub(crate) fn virtual_node(parent_uid: NodeUid, name: String, timestamp: i64) ->
|
|||||||
is_shared_publicly: false,
|
is_shared_publicly: false,
|
||||||
signature_email: None,
|
signature_email: None,
|
||||||
membership: None,
|
membership: None,
|
||||||
|
// A synthetic folder is neither a photo nor an album.
|
||||||
|
photo: None,
|
||||||
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+108
-157
@@ -4,8 +4,10 @@ pub(crate) mod pages;
|
|||||||
pub(crate) mod widgets;
|
pub(crate) mod widgets;
|
||||||
|
|
||||||
use pages::activity::*;
|
use pages::activity::*;
|
||||||
|
use pages::albums::*;
|
||||||
use pages::browser::*;
|
use pages::browser::*;
|
||||||
use pages::devices::*;
|
use pages::devices::*;
|
||||||
|
use pages::locations::*;
|
||||||
use pages::login::*;
|
use pages::login::*;
|
||||||
use pages::photo_viewer::*;
|
use pages::photo_viewer::*;
|
||||||
use pages::photos::*;
|
use pages::photos::*;
|
||||||
@@ -42,13 +44,15 @@ use pdfs_core::auth;
|
|||||||
use pdfs_core::config::AppDirs;
|
use pdfs_core::config::AppDirs;
|
||||||
|
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ActivityEntry, ActivityKind, BookmarkInfo, DeviceInfo, DirEntry, ErrorKind, InvitationInfo,
|
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, DeviceInfo, DirEntry, ErrorKind,
|
||||||
JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response,
|
InvitationInfo, JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response,
|
||||||
RestorableFolder, RestoreItem, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
|
RestorableFolder, RestoreItem, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
|
||||||
SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection, TransferItem, pending_summary,
|
SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection, TransferItem, pending_summary,
|
||||||
send,
|
send,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
||||||
|
|
||||||
use pdfs_core::service;
|
use pdfs_core::service;
|
||||||
|
|
||||||
const APP_ID: &str = "io.narl.proton-drive-linux";
|
const APP_ID: &str = "io.narl.proton-drive-linux";
|
||||||
@@ -113,6 +117,7 @@ struct Ui {
|
|||||||
pub(crate) shared: SharedState,
|
pub(crate) shared: SharedState,
|
||||||
pub(crate) shared_by_me: SharedByMeState,
|
pub(crate) shared_by_me: SharedByMeState,
|
||||||
pub(crate) devices: DevicesState,
|
pub(crate) devices: DevicesState,
|
||||||
|
pub(crate) locations: LocationsState,
|
||||||
pub(crate) activity: ActivityState,
|
pub(crate) activity: ActivityState,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,23 +139,10 @@ impl Ui {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The aspect ratio (w/h) to lay `uid`'s tile out at: the real one once its
|
/// Remember a decoded thumbnail, evicting the oldest once the cache is full.
|
||||||
/// thumbnail has been seen, otherwise [`RATIO_UNKNOWN`].
|
/// Scrolling back over a day then repaints from memory rather than decoding
|
||||||
fn ratio(&self, uid: &str) -> f64 {
|
/// the same JPEGs off disk again.
|
||||||
self.gallery
|
fn store_texture(&self, uid: &str, texture: gtk4::gdk::Texture) {
|
||||||
.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());
|
|
||||||
|
|
||||||
let mut cache = self.gallery.photo_tex.borrow_mut();
|
let mut cache = self.gallery.photo_tex.borrow_mut();
|
||||||
let mut order = self.gallery.photo_tex_order.borrow_mut();
|
let mut order = self.gallery.photo_tex_order.borrow_mut();
|
||||||
if cache.insert(uid.to_string(), texture).is_none() {
|
if cache.insert(uid.to_string(), texture).is_none() {
|
||||||
@@ -161,58 +153,6 @@ impl Ui {
|
|||||||
cache.remove(&old);
|
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 +223,23 @@ fn load_proton_theme() {
|
|||||||
.viewer-spinner {{ color: white; }}\n\
|
.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-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\
|
.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\
|
.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: 10px; background: alpha(currentColor, 0.06); box-shadow: none; transition: filter 160ms ease, box-shadow 160ms ease; }}\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 {{ filter: brightness(1.06); box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); }}\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-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-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-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-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\
|
.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 {{ 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\
|
.card:hover {{ transform: scale(1.02); filter: brightness(0.9); }}\n\
|
||||||
.navigation-sidebar row {{ border-radius: 8px; margin: 2px 6px; }}\n\
|
.navigation-sidebar row {{ border-radius: 8px; margin: 2px 6px; }}\n\
|
||||||
@@ -333,6 +281,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
let (shared_page, shared_widgets) = build_shared_page();
|
let (shared_page, shared_widgets) = build_shared_page();
|
||||||
let (shared_by_me_page, shared_by_me_widgets) = build_shared_by_me_page();
|
let (shared_by_me_page, shared_by_me_widgets) = build_shared_by_me_page();
|
||||||
let (devices_page, devices_widgets) = build_devices_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 (activity_page, activity_widgets) = build_activity_page();
|
||||||
let (trash_page, trash_widgets) = build_trash_page();
|
let (trash_page, trash_widgets) = build_trash_page();
|
||||||
stack.add_named(&login_page, Some("login"));
|
stack.add_named(&login_page, Some("login"));
|
||||||
@@ -342,6 +291,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
stack.add_named(&shared_by_me_page, Some("sharedbyme"));
|
stack.add_named(&shared_by_me_page, Some("sharedbyme"));
|
||||||
stack.add_named(&shared_page, Some("shared"));
|
stack.add_named(&shared_page, Some("shared"));
|
||||||
stack.add_named(&devices_page, Some("devices"));
|
stack.add_named(&devices_page, Some("devices"));
|
||||||
|
stack.add_named(&locations_page, Some("locations"));
|
||||||
stack.add_named(&activity_page, Some("activity"));
|
stack.add_named(&activity_page, Some("activity"));
|
||||||
stack.add_named(&trash_page, Some("trash"));
|
stack.add_named(&trash_page, Some("trash"));
|
||||||
|
|
||||||
@@ -456,9 +406,21 @@ fn build_window(app: &adw::Application) {
|
|||||||
retry: gallery_widgets.retry.clone(),
|
retry: gallery_widgets.retry.clone(),
|
||||||
more: gallery_widgets.more.clone(),
|
more: gallery_widgets.more.clone(),
|
||||||
upload: gallery_widgets.upload.clone(),
|
upload: gallery_widgets.upload.clone(),
|
||||||
|
title: gallery_widgets.title.clone(),
|
||||||
subtitle: gallery_widgets.subtitle.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),
|
kind: Cell::new(None),
|
||||||
tabs: gallery_widgets.tabs.clone(),
|
tabs: gallery_widgets.tabs.clone(),
|
||||||
|
counts: Cell::new(None),
|
||||||
dates: gallery_widgets.dates.clone(),
|
dates: gallery_widgets.dates.clone(),
|
||||||
date_ranges: RefCell::new(vec![None]),
|
date_ranges: RefCell::new(vec![None]),
|
||||||
range: Cell::new(None),
|
range: Cell::new(None),
|
||||||
@@ -467,8 +429,6 @@ fn build_window(app: &adw::Application) {
|
|||||||
width: Cell::new(0),
|
width: Cell::new(0),
|
||||||
photo_tex: RefCell::new(HashMap::new()),
|
photo_tex: RefCell::new(HashMap::new()),
|
||||||
photo_tex_order: RefCell::new(VecDeque::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()),
|
photo_nothumb: RefCell::new(HashSet::new()),
|
||||||
thumb_wanted: RefCell::new(HashMap::new()),
|
thumb_wanted: RefCell::new(HashMap::new()),
|
||||||
thumb_queue: RefCell::new(VecDeque::new()),
|
thumb_queue: RefCell::new(VecDeque::new()),
|
||||||
@@ -513,6 +473,15 @@ fn build_window(app: &adw::Application) {
|
|||||||
inflight: Cell::new(false),
|
inflight: Cell::new(false),
|
||||||
loaded_at: Cell::new(None),
|
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 {
|
activity: ActivityState {
|
||||||
content: activity_widgets.content.clone(),
|
content: activity_widgets.content.clone(),
|
||||||
status: activity_widgets.status.clone(),
|
status: activity_widgets.status.clone(),
|
||||||
@@ -523,8 +492,6 @@ fn build_window(app: &adw::Application) {
|
|||||||
key: RefCell::new(None),
|
key: RefCell::new(None),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
ui.load_ratios();
|
|
||||||
|
|
||||||
wire_login(&ui);
|
wire_login(&ui);
|
||||||
wire_logout(&ui, &main_widgets.logout_button);
|
wire_logout(&ui, &main_widgets.logout_button);
|
||||||
wire_settings(
|
wire_settings(
|
||||||
@@ -543,15 +510,12 @@ fn build_window(app: &adw::Application) {
|
|||||||
wire_details(&ui);
|
wire_details(&ui);
|
||||||
wire_search(&ui);
|
wire_search(&ui);
|
||||||
wire_gallery(&ui, &gallery_widgets.list, &gallery_widgets.scroll);
|
wire_gallery(&ui, &gallery_widgets.list, &gallery_widgets.scroll);
|
||||||
|
wire_albums(&ui);
|
||||||
wire_trash(&ui, &trash_widgets.list, &trash_widgets.empty);
|
wire_trash(&ui, &trash_widgets.list, &trash_widgets.empty);
|
||||||
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
|
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
|
||||||
wire_shared_by_me(&ui, &shared_by_me_widgets.retry);
|
wire_shared_by_me(&ui, &shared_by_me_widgets.retry);
|
||||||
wire_devices(
|
wire_devices(&ui, &devices_widgets.retry, &devices_widgets.restore);
|
||||||
&ui,
|
wire_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder);
|
||||||
&devices_widgets.retry,
|
|
||||||
&devices_widgets.add_folder,
|
|
||||||
&devices_widgets.restore,
|
|
||||||
);
|
|
||||||
wire_activity(&ui, &activity_widgets.retry);
|
wire_activity(&ui, &activity_widgets.retry);
|
||||||
wire_refresh(
|
wire_refresh(
|
||||||
&ui,
|
&ui,
|
||||||
@@ -562,6 +526,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
&shared_widgets.refresh,
|
&shared_widgets.refresh,
|
||||||
&shared_by_me_widgets.refresh,
|
&shared_by_me_widgets.refresh,
|
||||||
&devices_widgets.refresh,
|
&devices_widgets.refresh,
|
||||||
|
&locations_widgets.refresh,
|
||||||
&activity_widgets.refresh,
|
&activity_widgets.refresh,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -584,6 +549,8 @@ fn build_window(app: &adw::Application) {
|
|||||||
Some("shared") => load_shared(&ui_nav),
|
Some("shared") => load_shared(&ui_nav),
|
||||||
Some("devices") if page_fresh(&ui_nav.devices.loaded_at) => {}
|
Some("devices") if page_fresh(&ui_nav.devices.loaded_at) => {}
|
||||||
Some("devices") => load_devices(&ui_nav),
|
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
|
// Activity is intentionally not TTL-cached: it changes out from under
|
||||||
// the page as background uploads and edits complete, so it reloads on
|
// the page as background uploads and edits complete, so it reloads on
|
||||||
// every visit to stay live.
|
// every visit to stay live.
|
||||||
@@ -612,12 +579,10 @@ fn build_window(app: &adw::Application) {
|
|||||||
glib::ControlFlow::Continue
|
glib::ControlFlow::Continue
|
||||||
});
|
});
|
||||||
let cell = RefCell::new(Some(source));
|
let cell = RefCell::new(Some(source));
|
||||||
let ui_close = ui.clone();
|
|
||||||
window.connect_close_request(move |_| {
|
window.connect_close_request(move |_| {
|
||||||
if let Some(id) = cell.borrow_mut().take() {
|
if let Some(id) = cell.borrow_mut().take() {
|
||||||
id.remove();
|
id.remove();
|
||||||
}
|
}
|
||||||
ui_close.save_ratios();
|
|
||||||
glib::Propagation::Proceed
|
glib::Propagation::Proceed
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -626,12 +591,13 @@ fn build_window(app: &adw::Application) {
|
|||||||
|
|
||||||
/// The sidebar destinations, in order: the row index is the index into this table,
|
/// The sidebar destinations, in order: the row index is the index into this table,
|
||||||
/// and each entry is `(stack page name, label, icon)`.
|
/// 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"),
|
("browser", "My files", "folder-symbolic"),
|
||||||
("sharedbyme", "Shared", "emblem-shared-symbolic"),
|
("sharedbyme", "Shared", "emblem-shared-symbolic"),
|
||||||
("shared", "Shared with me", "system-users-symbolic"),
|
("shared", "Shared with me", "system-users-symbolic"),
|
||||||
|
("locations", "Locations", "drive-harddisk-symbolic"),
|
||||||
("devices", "Computers", "computer-symbolic"),
|
("devices", "Computers", "computer-symbolic"),
|
||||||
("gallery", "Photos", "image-x-generic-symbolic"),
|
("gallery", "Gallery", "image-x-generic-symbolic"),
|
||||||
("activity", "Activity", "document-open-recent-symbolic"),
|
("activity", "Activity", "document-open-recent-symbolic"),
|
||||||
("trash", "Trash", "user-trash-symbolic"),
|
("trash", "Trash", "user-trash-symbolic"),
|
||||||
("main", "Settings", "emblem-system-symbolic"),
|
("main", "Settings", "emblem-system-symbolic"),
|
||||||
@@ -734,7 +700,15 @@ fn reload_current_page(ui: &Rc<Ui>) {
|
|||||||
let path = ui.browser.path.borrow().clone();
|
let path = ui.browser.path.borrow().clone();
|
||||||
refresh_then(ui, RefreshScope::Dir { path }, load_browser);
|
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("trash") => refresh_then(ui, RefreshScope::Trash, load_trash),
|
||||||
Some("shared") => {
|
Some("shared") => {
|
||||||
ui.shared.loaded_at.set(None);
|
ui.shared.loaded_at.set(None);
|
||||||
@@ -748,6 +722,10 @@ fn reload_current_page(ui: &Rc<Ui>) {
|
|||||||
ui.devices.loaded_at.set(None);
|
ui.devices.loaded_at.set(None);
|
||||||
load_devices(ui);
|
load_devices(ui);
|
||||||
}
|
}
|
||||||
|
Some("locations") => {
|
||||||
|
ui.locations.loaded_at.set(None);
|
||||||
|
load_locations(ui);
|
||||||
|
}
|
||||||
Some("activity") => load_activity(ui),
|
Some("activity") => load_activity(ui),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -1045,81 +1023,54 @@ fn human_bytes(bytes: u64) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// A justified row must fill the content width exactly: tile widths plus the
|
/// The full width one row of `columns` tiles occupies, gaps included.
|
||||||
/// gaps between them add up to the width, with nothing left ragged.
|
fn row_width(columns: usize, edge: i32) -> i32 {
|
||||||
fn row_width(row: &[(i32, i32)]) -> i32 {
|
edge * columns as i32 + TILE_GAP * (columns as i32 - 1)
|
||||||
let gaps = TILE_GAP * (row.len().saturating_sub(1)) as i32;
|
|
||||||
row.iter().map(|(width, _)| *width).sum::<i32>() + gaps
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn justified_rows_fill_the_width_exactly() {
|
fn the_grid_spans_the_content_width() {
|
||||||
let ratios = [
|
// Whatever the width, the columns plus their gaps land on it (bar the
|
||||||
1.5, 0.75, 1.5, 1.33, 0.66, 1.5, 1.0, 2.2, 1.5, 0.8, 1.5, 1.6,
|
// few px integer division cannot divide), so no ragged right margin.
|
||||||
];
|
for width in [640, 900, 1000, 1440, 1920, 2560] {
|
||||||
let rows = plan_rows(&ratios, 180.0, 1000.0);
|
let (columns, edge) = plan_grid(TILE_DEFAULT, width);
|
||||||
|
let used = row_width(columns, edge);
|
||||||
assert!(
|
assert!(
|
||||||
rows.len() > 1,
|
used <= width && width - used < columns as i32,
|
||||||
"12 photos at 180px should need several rows"
|
"{columns} x {edge}px = {used}px does not span {width}px"
|
||||||
);
|
);
|
||||||
// Every row but the trailing one is justified.
|
|
||||||
for row in &rows[..rows.len() - 1] {
|
|
||||||
assert_eq!(row_width(row), 1000);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_photo_lands_in_exactly_one_row() {
|
fn tiles_stay_near_the_target_size() {
|
||||||
let ratios: Vec<f64> = (0..37).map(|i| 0.5 + f64::from(i % 5) * 0.4).collect();
|
// The column count rounds and the edge absorbs the remainder, so a tile
|
||||||
let rows = plan_rows(&ratios, 180.0, 1000.0);
|
// is never more than one gap-and-a-bit away from what was asked for.
|
||||||
let tiles: usize = rows.iter().map(Vec::len).sum();
|
let (columns, edge) = plan_grid(TILE_DEFAULT, 1920);
|
||||||
assert_eq!(tiles, ratios.len());
|
assert!(columns >= 7, "1920px should hold several 220px tiles");
|
||||||
}
|
|
||||||
|
|
||||||
#[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);
|
|
||||||
assert!(
|
assert!(
|
||||||
rows.iter()
|
(edge - TILE_DEFAULT).abs() < TILE_DEFAULT / 3,
|
||||||
.flatten()
|
"{edge}px is not near the {TILE_DEFAULT}px target"
|
||||||
.all(|(width, height)| *width > 0 && *height > 0)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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,
|
cached: false,
|
||||||
uid: h.uid.clone(),
|
uid: h.uid.clone(),
|
||||||
path: h.path.clone(),
|
path: h.path.clone(),
|
||||||
|
role: String::new(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
entries.sort_by(|a, b| {
|
entries.sort_by(|a, b| {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ pub(crate) struct DevicesState {
|
|||||||
pub(crate) group: adw::PreferencesGroup,
|
pub(crate) group: adw::PreferencesGroup,
|
||||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
||||||
pub(crate) sync_group: adw::PreferencesGroup,
|
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>>,
|
pub(crate) sync_rows: RefCell<Vec<gtk4::Widget>>,
|
||||||
/// "Rename" action in the "This computer" header. Insensitive until the
|
/// "Rename" action in the "This computer" header. Insensitive until the
|
||||||
/// device list identifies this machine's own device.
|
/// device list identifies this machine's own device.
|
||||||
@@ -25,7 +27,8 @@ pub(crate) struct DevicesState {
|
|||||||
pub(crate) struct DevicesWidgets {
|
pub(crate) struct DevicesWidgets {
|
||||||
pub(crate) content: gtk4::Stack,
|
pub(crate) content: gtk4::Stack,
|
||||||
pub(crate) status: adw::StatusPage,
|
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,
|
pub(crate) sync_group: adw::PreferencesGroup,
|
||||||
/// "Rename" in the "This computer" header — renames this machine's device.
|
/// "Rename" in the "This computer" header — renames this machine's device.
|
||||||
pub(crate) rename_this: gtk4::Button,
|
pub(crate) rename_this: gtk4::Button,
|
||||||
@@ -33,7 +36,6 @@ pub(crate) struct DevicesWidgets {
|
|||||||
/// machine's own device is deliberately not among them; see
|
/// machine's own device is deliberately not among them; see
|
||||||
/// [`repaint_devices`].
|
/// [`repaint_devices`].
|
||||||
pub(crate) group: adw::PreferencesGroup,
|
pub(crate) group: adw::PreferencesGroup,
|
||||||
pub(crate) add_folder: gtk4::Button,
|
|
||||||
/// "Restore Folders" — re-attach this device's remote folders to local
|
/// "Restore Folders" — re-attach this device's remote folders to local
|
||||||
/// directories after adopting it on a new machine.
|
/// directories after adopting it on a new machine.
|
||||||
pub(crate) restore: gtk4::Button,
|
pub(crate) restore: gtk4::Button,
|
||||||
@@ -54,11 +56,6 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
|||||||
titles.set_hexpand(true);
|
titles.set_hexpand(true);
|
||||||
titles.append(&title);
|
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()
|
let restore = gtk4::Button::builder()
|
||||||
.label("Restore Folders")
|
.label("Restore Folders")
|
||||||
.tooltip_text("Sync this computer's Drive folders back to local directories")
|
.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(&titles);
|
||||||
header.append(&refresh);
|
header.append(&refresh);
|
||||||
header.append(&restore);
|
header.append(&restore);
|
||||||
header.append(&add_folder);
|
|
||||||
|
|
||||||
// The description spells out what the per-row On-demand switch does to the
|
// This page is about device *identity* — which computer this is, which other
|
||||||
// local copy, because the switch itself can't: turning it on *deletes* the
|
// computers back up to the account, and how to adopt one. The folders
|
||||||
// files from this disk, which is not something to discover afterwards.
|
// 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()
|
let sync_group = adw::PreferencesGroup::builder()
|
||||||
.title("This computer")
|
.title("This computer")
|
||||||
.description(
|
.description("The device this machine backs up to.")
|
||||||
"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.",
|
|
||||||
)
|
|
||||||
.build();
|
.build();
|
||||||
// Rename control for *this* machine's device, in the section header. The
|
// 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
|
// 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,
|
sync_group,
|
||||||
rename_this,
|
rename_this,
|
||||||
group,
|
group,
|
||||||
add_folder,
|
|
||||||
restore,
|
restore,
|
||||||
retry,
|
retry,
|
||||||
refresh,
|
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.
|
/// Install the Devices page's retry button and the "Restore Folders" action.
|
||||||
pub(crate) fn wire_devices(
|
pub(crate) fn wire_devices(ui: &Rc<Ui>, retry: >k4::Button, restore: >k4::Button) {
|
||||||
ui: &Rc<Ui>,
|
|
||||||
retry: >k4::Button,
|
|
||||||
add_folder: >k4::Button,
|
|
||||||
restore: >k4::Button,
|
|
||||||
) {
|
|
||||||
let ui_retry = ui.clone();
|
let ui_retry = ui.clone();
|
||||||
retry.connect_clicked(move |_| {
|
retry.connect_clicked(move |_| {
|
||||||
service::restart();
|
service::restart();
|
||||||
load_devices(&ui_retry);
|
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();
|
let ui_restore = ui.clone();
|
||||||
restore.connect_clicked(move |_| prompt_restore_folders(&ui_restore));
|
restore.connect_clicked(move |_| prompt_restore_folders(&ui_restore));
|
||||||
let ui_ren = ui.clone();
|
let ui_ren = ui.clone();
|
||||||
@@ -224,9 +209,9 @@ pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
|||||||
return;
|
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");
|
ui.devices.content.set_visible_child_name("list");
|
||||||
repaint_sync_folders(&ui, &folders);
|
repaint_this_computer(&ui, &folders);
|
||||||
|
|
||||||
let devices = match devices_reply {
|
let devices = match devices_reply {
|
||||||
Ok(Ok(Response::Devices { items })) => items,
|
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.
|
/// The daemon didn't answer the Devices page.
|
||||||
pub(crate) fn devices_unreachable(ui: &Rc<Ui>) {
|
pub(crate) fn devices_unreachable(ui: &Rc<Ui>) {
|
||||||
if service::is_failed() || !service::is_active() {
|
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.
|
/// Rebuild the "This computer" section: one row naming the device this machine
|
||||||
pub(crate) fn repaint_sync_folders(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
|
/// 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(..) {
|
for row in ui.devices.sync_rows.borrow_mut().drain(..) {
|
||||||
ui.devices.sync_group.remove(&row);
|
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() {
|
if folders.is_empty() {
|
||||||
let row = adw::ActionRow::builder()
|
return "No folders backed up yet — add one under Locations.".to_string();
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
let count = match folders.len() {
|
||||||
for f in folders {
|
1 => "1 folder backed up".to_string(),
|
||||||
// A running pass describes itself ("Uploading photo.jpg — 12 of 40");
|
n => format!("{n} folders backed up"),
|
||||||
// otherwise fall back to the folder's resting state.
|
};
|
||||||
let status = match &f.progress {
|
let attention = folders
|
||||||
Some(p) => sync_progress_label(p),
|
.iter()
|
||||||
None => sync_state_label(&f.state).to_string(),
|
.filter(|f| f.state == "error" || f.state == "conflict")
|
||||||
};
|
.count();
|
||||||
// A queued switch is where the folder is *going*, which is what the user
|
match attention {
|
||||||
// just asked for and is waiting to see happen — so it leads the subtitle
|
0 => format!("{count} · manage them under Locations"),
|
||||||
// instead of the mode the folder is still technically in.
|
1 => format!("{count} · 1 needs attention"),
|
||||||
let subtitle = match (f.pending_mode.as_deref(), f.mode.as_str()) {
|
n => format!("{count} · {n} need attention"),
|
||||||
(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());
|
|
||||||
}
|
}
|
||||||
*ui.devices.sync_rows.borrow_mut() = rows;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Human label for a synced folder's `state` column.
|
/// 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
|
// device and creating the remote folder is off-socket network
|
||||||
// work. A fixed delay here either fires too early (empty list)
|
// work. A fixed delay here either fires too early (empty list)
|
||||||
// or too late (row flashes in). Instead let the periodic
|
// 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.
|
// actually lands, which is what keeps a running pass live too.
|
||||||
toast(&ui, "Syncing folder…");
|
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 activity;
|
||||||
|
pub(crate) mod albums;
|
||||||
pub(crate) mod browser;
|
pub(crate) mod browser;
|
||||||
pub(crate) mod devices;
|
pub(crate) mod devices;
|
||||||
|
pub(crate) mod locations;
|
||||||
pub(crate) mod login;
|
pub(crate) mod login;
|
||||||
pub(crate) mod photo_viewer;
|
pub(crate) mod photo_viewer;
|
||||||
pub(crate) mod photos;
|
pub(crate) mod photos;
|
||||||
|
|||||||
@@ -8,24 +8,53 @@ pub(crate) struct GalleryState {
|
|||||||
/// The day sections rendered by the Photos ListView, rebuilt from
|
/// The day sections rendered by the Photos ListView, rebuilt from
|
||||||
/// [`Self::model`] by [`repaint_gallery`].
|
/// [`Self::model`] by [`repaint_gallery`].
|
||||||
pub(crate) groups: gio::ListStore,
|
pub(crate) groups: gio::ListStore,
|
||||||
/// Target row height in px, retuned by Ctrl+scroll / Ctrl+± (see
|
/// Target tile edge in px, retuned by Ctrl+scroll / Ctrl+± (see
|
||||||
/// [`zoom_gallery`]). Each tile keeps its own aspect ratio; rows are
|
/// [`zoom_gallery`]). The grid fits as many square tiles of about this size
|
||||||
/// justified to the content width around this height.
|
/// as the content width holds.
|
||||||
pub(crate) tile: Cell<i32>,
|
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) content: gtk4::Stack,
|
||||||
pub(crate) status: adw::StatusPage,
|
pub(crate) status: adw::StatusPage,
|
||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
pub(crate) more: gtk4::Button,
|
pub(crate) more: gtk4::Button,
|
||||||
pub(crate) upload: 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.
|
/// "1,204 photos" under the page title.
|
||||||
pub(crate) subtitle: gtk4::Label,
|
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`
|
/// Which tab (Photos / Videos / Raw) the timeline is filtered to, or `None`
|
||||||
/// for All. Read by [`load_gallery`] and set by the filter toggles.
|
/// for All. Read by [`load_gallery`] and set by the filter toggles.
|
||||||
pub(crate) kind: Cell<Option<PhotoKind>>,
|
pub(crate) kind: Cell<Option<PhotoKind>>,
|
||||||
/// The filter toggles, index-aligned with [`kind_for_tab`], kept so their
|
/// The filter toggles, index-aligned with [`kind_for_tab`], kept so their
|
||||||
/// labels can carry live per-kind counts.
|
/// labels can carry live per-kind counts.
|
||||||
pub(crate) tabs: [gtk4::ToggleButton; 4],
|
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 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
|
/// the `[from, to)` window each of its rows selects (index-aligned; `None` is
|
||||||
/// "All dates"). Selecting a row loads that month via [`load_gallery`].
|
/// "All dates"). Selecting a row loads that month via [`load_gallery`].
|
||||||
@@ -40,21 +69,14 @@ pub(crate) struct GalleryState {
|
|||||||
/// True while a timeline page is in flight, so the scroll-to-the-end paging
|
/// 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.
|
/// can't fire a second request for the page already coming.
|
||||||
pub(crate) loading: Cell<bool>,
|
pub(crate) loading: Cell<bool>,
|
||||||
/// Content width the sections are currently justified to. Updated when the
|
/// Content width the grid is currently laid out to. Updated when the
|
||||||
/// ListView is resized, which re-justifies the visible sections.
|
/// ListView is resized, which re-flows the visible sections.
|
||||||
pub(crate) width: Cell<i32>,
|
pub(crate) width: Cell<i32>,
|
||||||
/// Decoded thumbnails by photo uid, with the insertion order that evicts the
|
/// Decoded thumbnails by photo uid, with the insertion order that evicts the
|
||||||
/// oldest past [`TEXTURE_CACHE_MAX`]. Scrolling back over a day therefore
|
/// oldest past [`TEXTURE_CACHE_MAX`]. Scrolling back over a day therefore
|
||||||
/// repaints from memory instead of re-decoding from disk.
|
/// repaints from memory instead of re-decoding from disk.
|
||||||
pub(crate) photo_tex: RefCell<HashMap<String, gtk4::gdk::Texture>>,
|
pub(crate) photo_tex: RefCell<HashMap<String, gtk4::gdk::Texture>>,
|
||||||
pub(crate) photo_tex_order: RefCell<VecDeque<String>>,
|
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
|
/// 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.
|
/// can never be filled isn't requested again on every scroll past it.
|
||||||
pub(crate) photo_nothumb: RefCell<HashSet<String>>,
|
pub(crate) photo_nothumb: RefCell<HashSet<String>>,
|
||||||
@@ -75,46 +97,33 @@ pub(crate) struct GalleryState {
|
|||||||
pub(crate) decode_queue: RefCell<VecDeque<(String, String)>>,
|
pub(crate) decode_queue: RefCell<VecDeque<(String, String)>>,
|
||||||
pub(crate) decode_idle: Cell<bool>,
|
pub(crate) decode_idle: Cell<bool>,
|
||||||
/// Pending debounce timers for the thumbnail queue flush and the section
|
/// 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) thumb_source: RefCell<Option<glib::SourceId>>,
|
||||||
pub(crate) relayout_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
|
/// The day sections currently realised by the ListView, by their index in
|
||||||
/// [`Self::groups`]. A learned ratio or a resize re-justifies these
|
/// [`Self::groups`]. A resize or a zoom step re-flows these in place —
|
||||||
/// in place — rebuilding the ListStore instead would reset the scroll
|
/// rebuilding the ListStore instead would reset the scroll position out from
|
||||||
/// position out from under the user.
|
/// under the user.
|
||||||
pub(crate) bound: RefCell<HashMap<u32, gtk4::Box>>,
|
pub(crate) bound: RefCell<HashMap<u32, gtk4::Box>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
||||||
pub(crate) const PHOTOS_PAGE: usize = 60;
|
pub(crate) const PHOTOS_PAGE: usize = 60;
|
||||||
|
|
||||||
/// Gallery row height in px: the zoom range, its default, and the step one
|
/// Gallery tile edge 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
|
/// Ctrl+scroll notch (or Ctrl+±) moves it by. The grid divides the content width
|
||||||
/// content width, so this is the *target* height a row lands near, not an exact
|
/// evenly, so this is the *target* a tile lands near rather than its exact size
|
||||||
/// tile size (see [`justify_rows`]).
|
/// (see [`plan_grid`]).
|
||||||
pub(crate) const TILE_MIN: i32 = 90;
|
pub(crate) const TILE_MIN: i32 = 90;
|
||||||
|
|
||||||
pub(crate) const TILE_MAX: i32 = 340;
|
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;
|
pub(crate) const TILE_STEP: i32 = 30;
|
||||||
|
|
||||||
/// Gap between tiles, horizontally and vertically.
|
/// Gap between tiles, horizontally and vertically.
|
||||||
pub(crate) const TILE_GAP: i32 = 6;
|
pub(crate) const TILE_GAP: i32 = 8;
|
||||||
|
|
||||||
/// 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;
|
|
||||||
|
|
||||||
/// How many thumbnails one on-demand [`Request::PhotoThumbs`] batch asks for.
|
/// 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
|
/// Small, so the first tiles on screen fill in quickly rather than the whole
|
||||||
@@ -134,7 +143,7 @@ pub(crate) const THUMB_RETRY: Duration = Duration::from_secs(4);
|
|||||||
/// this caps the gallery's footprint while covering several screens of scroll.
|
/// this caps the gallery's footprint while covering several screens of scroll.
|
||||||
pub(crate) const TEXTURE_CACHE_MAX: usize = 600;
|
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);
|
pub(crate) const RELAYOUT_DEBOUNCE: Duration = Duration::from_millis(80);
|
||||||
|
|
||||||
/// One day-section of the photos timeline: a heading plus the photos captured
|
/// One day-section of the photos timeline: a heading plus the photos captured
|
||||||
@@ -153,9 +162,11 @@ pub(crate) struct GalleryWidgets {
|
|||||||
pub(crate) model: gio::ListStore,
|
pub(crate) model: gio::ListStore,
|
||||||
/// Day sections rendered by the ListView, derived from `model`.
|
/// Day sections rendered by the ListView, derived from `model`.
|
||||||
pub(crate) groups: gio::ListStore,
|
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) content: gtk4::Stack,
|
||||||
pub(crate) status: adw::StatusPage,
|
pub(crate) status: adw::StatusPage,
|
||||||
|
pub(crate) title: gtk4::Label,
|
||||||
pub(crate) subtitle: gtk4::Label,
|
pub(crate) subtitle: gtk4::Label,
|
||||||
pub(crate) more: gtk4::Button,
|
pub(crate) more: gtk4::Button,
|
||||||
pub(crate) list: gtk4::ListView,
|
pub(crate) list: gtk4::ListView,
|
||||||
@@ -163,6 +174,17 @@ pub(crate) struct GalleryWidgets {
|
|||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
pub(crate) upload: gtk4::Button,
|
pub(crate) upload: gtk4::Button,
|
||||||
pub(crate) refresh: 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
|
/// The All / Photos / Videos / Raw filter toggles, in that order (index maps
|
||||||
/// to [`kind_for_tab`]).
|
/// to [`kind_for_tab`]).
|
||||||
pub(crate) tabs: [gtk4::ToggleButton; 4],
|
pub(crate) tabs: [gtk4::ToggleButton; 4],
|
||||||
@@ -217,7 +239,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
more.add_css_class("pill");
|
more.add_css_class("pill");
|
||||||
more.set_visible(false);
|
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.
|
// width, and a stray hscrollbar would fight the layout.
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
@@ -226,7 +248,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let title_label = gtk4::Label::builder()
|
let title_label = gtk4::Label::builder()
|
||||||
.label("Photos")
|
.label("Gallery")
|
||||||
.halign(gtk4::Align::Start)
|
.halign(gtk4::Align::Start)
|
||||||
.build();
|
.build();
|
||||||
title_label.add_css_class("title-2");
|
title_label.add_css_class("title-2");
|
||||||
@@ -243,6 +265,17 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
titles.append(&title_label);
|
titles.append(&title_label);
|
||||||
titles.append(&subtitle);
|
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()
|
let upload = gtk4::Button::builder()
|
||||||
.label("Upload")
|
.label("Upload")
|
||||||
.icon_name("list-add-symbolic")
|
.icon_name("list-add-symbolic")
|
||||||
@@ -253,6 +286,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
let refresh = refresh_button();
|
let refresh = refresh_button();
|
||||||
|
|
||||||
let header_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
let header_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||||
|
header_box.append(&back);
|
||||||
header_box.append(&titles);
|
header_box.append(&titles);
|
||||||
header_box.append(&refresh);
|
header_box.append(&refresh);
|
||||||
header_box.append(&upload);
|
header_box.append(&upload);
|
||||||
@@ -287,23 +321,73 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
dates.add_css_class("pill");
|
dates.add_css_class("pill");
|
||||||
dates.set_tooltip_text(Some("Jump to a month"));
|
dates.set_tooltip_text(Some("Jump to a month"));
|
||||||
|
|
||||||
let filter_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
// The kind toggles and the date jump travel together: they filter the
|
||||||
filter_bar.append(&tab_group);
|
// 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);
|
||||||
let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||||
spacer.set_hexpand(true);
|
spacer.set_hexpand(true);
|
||||||
filter_bar.append(&spacer);
|
filters.append(&spacer);
|
||||||
filter_bar.append(&dates);
|
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.
|
// The timeline (plus its pager) or the status page, never both.
|
||||||
let timeline = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let timeline = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
timeline.append(&scroll);
|
timeline.append(&scroll);
|
||||||
timeline.append(&more);
|
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();
|
let content = gtk4::Stack::new();
|
||||||
content.set_vexpand(true);
|
content.set_vexpand(true);
|
||||||
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
||||||
content.add_named(&timeline, Some("timeline"));
|
content.add_named(&timeline, Some("timeline"));
|
||||||
content.add_named(&status, Some("status"));
|
content.add_named(&status, Some("status"));
|
||||||
|
content.add_named(&albums_stack, Some("albums"));
|
||||||
|
|
||||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
inner.set_margin_top(12);
|
inner.set_margin_top(12);
|
||||||
@@ -311,6 +395,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
inner.set_margin_start(12);
|
inner.set_margin_start(12);
|
||||||
inner.set_margin_end(12);
|
inner.set_margin_end(12);
|
||||||
inner.append(&header_box);
|
inner.append(&header_box);
|
||||||
|
inner.append(&view_switch);
|
||||||
inner.append(&filter_bar);
|
inner.append(&filter_bar);
|
||||||
inner.append(&content);
|
inner.append(&content);
|
||||||
|
|
||||||
@@ -321,6 +406,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
groups,
|
groups,
|
||||||
content,
|
content,
|
||||||
status,
|
status,
|
||||||
|
title: title_label,
|
||||||
subtitle,
|
subtitle,
|
||||||
more,
|
more,
|
||||||
list,
|
list,
|
||||||
@@ -330,6 +416,14 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
refresh,
|
refresh,
|
||||||
tabs,
|
tabs,
|
||||||
dates,
|
dates,
|
||||||
|
albums,
|
||||||
|
albums_stack,
|
||||||
|
albums_status,
|
||||||
|
photos_btn,
|
||||||
|
albums_btn,
|
||||||
|
view_switch,
|
||||||
|
back,
|
||||||
|
filters,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -416,16 +510,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
|
/// 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.
|
/// 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)) {
|
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 (photos, videos, raw) = counts;
|
||||||
let totals = [photos + videos + raw, photos, videos, raw];
|
let totals = [photos + videos + raw, photos, videos, raw];
|
||||||
for (index, tab) in ui.gallery.tabs.iter().enumerate() {
|
for (index, tab) in ui.gallery.tabs.iter().enumerate() {
|
||||||
let name = ["All", "Photos", "Videos", "Raw"][index];
|
let name = ["All", "Photos", "Videos", "Raw"][index];
|
||||||
let n = totals[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());
|
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
|
/// 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
|
/// and the upload button. Activating a thumbnail downloads the photo and opens it
|
||||||
/// in the in-app lightbox.
|
/// in the in-app lightbox.
|
||||||
@@ -485,8 +594,8 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::Sc
|
|||||||
});
|
});
|
||||||
list.set_factory(Some(&factory));
|
list.set_factory(Some(&factory));
|
||||||
|
|
||||||
// Rows are justified to the content width, so a resize re-justifies whatever
|
// The grid divides the content width, so a resize re-flows whatever is on
|
||||||
// is on screen (offscreen sections pick the new width up when they bind).
|
// screen (offscreen sections pick the new width up when they bind).
|
||||||
let ui_width = ui.clone();
|
let ui_width = ui.clone();
|
||||||
list.connect_notify_local(Some("width"), move |list, _| {
|
list.connect_notify_local(Some("width"), move |list, _| {
|
||||||
let width = list.width();
|
let width = list.width();
|
||||||
@@ -673,114 +782,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) struct Tile {
|
||||||
pub(crate) photo: PhotoItem,
|
pub(crate) photo: PhotoItem,
|
||||||
pub(crate) width: i32,
|
pub(crate) edge: i32,
|
||||||
pub(crate) height: i32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Break one day's photos into rows that each fill `width` exactly, at heights
|
/// Break one day's photos into rows of equal square tiles that span `width` —
|
||||||
/// near [`Ui::gallery_tile`] — the layout every modern photo gallery uses, and
|
/// the layout a phone gallery uses, and the reason a day holding two photos
|
||||||
/// the reason nothing here has to be cropped: each tile keeps its own aspect
|
/// looks like every other day rather than like a mistake.
|
||||||
/// ratio, and it is the row *height* that flexes to make the widths add up.
|
|
||||||
///
|
///
|
||||||
/// Greedy, one pass: keep adding photos to the row: the more photos share it, the
|
/// Each tile is centre-cropped to its square (the full frame is one click away
|
||||||
/// shorter it has to be to fit. The moment that height drops to the target, the
|
/// in the lightbox), so nothing here depends on knowing a photo's aspect ratio
|
||||||
/// row is as full as it should be and is emitted. The trailing row keeps the
|
/// and a row never has to be re-flowed when a thumbnail finally lands.
|
||||||
/// target height instead of being stretched, so a day with three photos doesn't
|
pub(crate) fn grid_rows(ui: &Rc<Ui>, photos: &[PhotoItem], width: i32) -> Vec<Vec<Tile>> {
|
||||||
/// blow them up to fill a screen-wide row.
|
let (columns, edge) = plan_grid(ui.gallery.tile.get(), width);
|
||||||
///
|
photos
|
||||||
/// Photos whose thumbnail hasn't been decoded yet are laid out at
|
.chunks(columns)
|
||||||
/// [`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()
|
|
||||||
.map(|row| {
|
.map(|row| {
|
||||||
row.into_iter()
|
row.iter()
|
||||||
.filter_map(|(width, height)| {
|
.map(|photo| Tile {
|
||||||
photos.next().map(|photo| Tile {
|
photo: photo.clone(),
|
||||||
photo: photo.clone(),
|
edge,
|
||||||
width,
|
|
||||||
height,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The row-packing math behind [`justify_rows`], over nothing but aspect ratios:
|
/// The grid math: how many columns fit in `width` at roughly `target` px per
|
||||||
/// takes each photo's ratio (w/h) in order and returns the pixel `(width,
|
/// tile, and the exact square edge that divides the width between them.
|
||||||
/// height)` of every tile, grouped into rows.
|
///
|
||||||
pub(crate) fn plan_rows(ratios: &[f64], target: f64, width: f64) -> Vec<Vec<(i32, i32)>> {
|
/// The column count is what rounds — the edge then absorbs the remainder, so the
|
||||||
let avail = width.max(f64::from(TILE_MIN));
|
/// grid spans the full width at every window size instead of leaving a ragged
|
||||||
let gap = f64::from(TILE_GAP);
|
/// margin. Never fewer than one column, however narrow the window gets.
|
||||||
|
pub(crate) fn plan_grid(target: i32, width: i32) -> (usize, i32) {
|
||||||
// The height a row of `n` photos with `sum_ratio` total ratio must take for
|
let avail = width.max(TILE_MIN);
|
||||||
// its widths (plus gaps) to add up to exactly `avail`.
|
let target = target.clamp(TILE_MIN, TILE_MAX);
|
||||||
let row_height = |sum_ratio: f64, n: usize| {
|
// A row of n tiles occupies n*edge + (n-1)*gap, so n tiles of the target size
|
||||||
let gaps = gap * (n.saturating_sub(1)) as f64;
|
// fit while n*(target + gap) - gap <= avail.
|
||||||
(avail - gaps) / sum_ratio
|
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);
|
||||||
let mut rows: Vec<Vec<(i32, i32)>> = Vec::new();
|
(columns, edge)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Size one row's tiles at `height`. A `justified` row is nudged so its widths
|
/// (Re)build a bound day-section's tiles: lay this day's photos out to the
|
||||||
/// 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
|
|
||||||
/// current content width and hand each tile whatever thumbnail is already in
|
/// 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
|
/// 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]) {
|
pub(crate) fn fill_section(ui: &Rc<Ui>, section: >k4::Box, photos: &[PhotoItem]) {
|
||||||
let Some(heading) = section.first_child() else {
|
let Some(heading) = section.first_child() else {
|
||||||
return;
|
return;
|
||||||
@@ -791,8 +841,11 @@ pub(crate) fn fill_section(ui: &Rc<Ui>, section: >k4::Box, photos: &[PhotoItem
|
|||||||
|
|
||||||
let width = gallery_width(ui);
|
let width = gallery_width(ui);
|
||||||
let rows = gtk4::Box::new(gtk4::Orientation::Vertical, TILE_GAP);
|
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);
|
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 {
|
for tile in row {
|
||||||
row_box.append(&photo_tile(ui, tile));
|
row_box.append(&photo_tile(ui, tile));
|
||||||
}
|
}
|
||||||
@@ -802,8 +855,8 @@ pub(crate) fn fill_section(ui: &Rc<Ui>, section: >k4::Box, photos: &[PhotoItem
|
|||||||
schedule_thumbs(ui);
|
schedule_thumbs(ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The width justified rows are laid out to: the ListView's own width, less a
|
/// The width the grid is laid out to: the ListView's own width, less a couple of
|
||||||
/// couple of px so a rounding error can't push a row into a horizontal overflow.
|
/// px so a rounding error can't push a row into a horizontal overflow.
|
||||||
/// Falls back to a sane guess before the first allocation.
|
/// Falls back to a sane guess before the first allocation.
|
||||||
pub(crate) fn gallery_width(ui: &Rc<Ui>) -> i32 {
|
pub(crate) fn gallery_width(ui: &Rc<Ui>) -> i32 {
|
||||||
match ui.gallery.width.get() {
|
match ui.gallery.width.get() {
|
||||||
@@ -822,12 +875,17 @@ pub(crate) fn gallery_width(ui: &Rc<Ui>) -> i32 {
|
|||||||
/// one keeps an image glyph instead of an empty rectangle.
|
/// one keeps an image glyph instead of an empty rectangle.
|
||||||
pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
|
pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
|
||||||
let picture = gtk4::Picture::builder()
|
let picture = gtk4::Picture::builder()
|
||||||
// The tile is exactly the thumbnail's own aspect ratio, so Cover scales
|
// Cover fills the square and crops the overflow, so a portrait photo
|
||||||
// it and crops nothing; it only bites during the brief window where the
|
// sits flush in its tile instead of floating in letterbox bars. The
|
||||||
// ratio is still a guess, and the re-justify then fixes the tile.
|
// 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)
|
.content_fit(gtk4::ContentFit::Cover)
|
||||||
.can_shrink(true)
|
.can_shrink(true)
|
||||||
|
.hexpand(true)
|
||||||
|
.vexpand(true)
|
||||||
.build();
|
.build();
|
||||||
|
// Zoomed on hover by the stylesheet; the tile clips the overflow.
|
||||||
|
picture.add_css_class("photo-thumb");
|
||||||
|
|
||||||
let placeholder = gtk4::Image::builder()
|
let placeholder = gtk4::Image::builder()
|
||||||
.icon_name("image-x-generic-symbolic")
|
.icon_name("image-x-generic-symbolic")
|
||||||
@@ -871,8 +929,8 @@ pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
|
|||||||
|
|
||||||
let button = gtk4::Button::builder()
|
let button = gtk4::Button::builder()
|
||||||
.child(&overlay)
|
.child(&overlay)
|
||||||
.width_request(tile.width)
|
.width_request(tile.edge)
|
||||||
.height_request(tile.height)
|
.height_request(tile.edge)
|
||||||
.tooltip_text(format_capture_time(tile.photo.capture_time))
|
.tooltip_text(format_capture_time(tile.photo.capture_time))
|
||||||
.build();
|
.build();
|
||||||
button.add_css_class("photo-tile");
|
button.add_css_class("photo-tile");
|
||||||
@@ -1052,7 +1110,6 @@ pub(crate) fn schedule_decode(ui: &Rc<Ui>) {
|
|||||||
let mut queue = ui.gallery.decode_queue.borrow_mut();
|
let mut queue = ui.gallery.decode_queue.borrow_mut();
|
||||||
(0..4).filter_map(|_| queue.pop_front()).collect()
|
(0..4).filter_map(|_| queue.pop_front()).collect()
|
||||||
};
|
};
|
||||||
let mut relayout = false;
|
|
||||||
for (uid, path) in batch {
|
for (uid, path) in batch {
|
||||||
let texture = match gtk4::gdk::Texture::from_filename(&path) {
|
let texture = match gtk4::gdk::Texture::from_filename(&path) {
|
||||||
Ok(texture) => texture,
|
Ok(texture) => texture,
|
||||||
@@ -1062,28 +1119,23 @@ pub(crate) fn schedule_decode(ui: &Rc<Ui>) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// A ratio we hadn't seen means the tile was sized against a guess.
|
ui.store_texture(&uid, texture.clone());
|
||||||
relayout |= ui.store_texture(&uid, texture.clone());
|
|
||||||
if let Some(picture) = ui.gallery.thumb_wanted.borrow_mut().remove(&uid) {
|
if let Some(picture) = ui.gallery.thumb_wanted.borrow_mut().remove(&uid) {
|
||||||
picture.set_paintable(Some(&texture));
|
picture.set_paintable(Some(&texture));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if relayout {
|
|
||||||
schedule_relayout(&ui);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ui.gallery.decode_queue.borrow().is_empty() {
|
if ui.gallery.decode_queue.borrow().is_empty() {
|
||||||
ui.gallery.decode_idle.set(false);
|
ui.gallery.decode_idle.set(false);
|
||||||
ui.save_ratios();
|
|
||||||
return glib::ControlFlow::Break;
|
return glib::ControlFlow::Break;
|
||||||
}
|
}
|
||||||
glib::ControlFlow::Continue
|
glib::ControlFlow::Continue
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-justify the sections on screen shortly. Debounced, because the triggers
|
/// Re-flow the sections on screen shortly. Debounced, because the triggers (a
|
||||||
/// (a window resize, a zoom step, a burst of decoded thumbnails) all arrive in
|
/// window resize, a zoom step) arrive in floods and only the final state
|
||||||
/// floods and only the final state matters.
|
/// matters.
|
||||||
pub(crate) fn schedule_relayout(ui: &Rc<Ui>) {
|
pub(crate) fn schedule_relayout(ui: &Rc<Ui>) {
|
||||||
if let Some(id) = ui.gallery.relayout_source.borrow_mut().take() {
|
if let Some(id) = ui.gallery.relayout_source.borrow_mut().take() {
|
||||||
id.remove();
|
id.remove();
|
||||||
@@ -1097,9 +1149,8 @@ pub(crate) fn schedule_relayout(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuild the tiles of the day sections currently on screen, at the current
|
/// 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
|
/// width and zoom. Sections that are *not* realised need no work: they lay
|
||||||
/// need no work: they justify themselves against the current state when the
|
/// themselves out against the current state when the ListView binds them.
|
||||||
/// ListView binds them.
|
|
||||||
pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
|
pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
|
||||||
let bound: Vec<(u32, gtk4::Box)> = ui
|
let bound: Vec<(u32, gtk4::Box)> = ui
|
||||||
.gallery
|
.gallery
|
||||||
@@ -1120,12 +1171,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) {
|
pub(crate) fn zoom_gallery(ui: &Rc<Ui>, delta: i32) {
|
||||||
set_gallery_tile(ui, ui.gallery.tile.get() + delta);
|
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) {
|
pub(crate) fn set_gallery_tile(ui: &Rc<Ui>, tile: i32) {
|
||||||
let tile = tile.clamp(TILE_MIN, TILE_MAX);
|
let tile = tile.clamp(TILE_MIN, TILE_MAX);
|
||||||
if tile == ui.gallery.tile.get() {
|
if tile == ui.gallery.tile.get() {
|
||||||
@@ -1175,17 +1226,35 @@ pub(crate) fn repaint_gallery(ui: &Rc<Ui>) {
|
|||||||
store.splice(len, store.n_items() - len, &[] as &[BoxedAnyObject]);
|
store.splice(len, store.n_items() - len, &[] as &[BoxedAnyObject]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let count = ui.gallery.model.n_items();
|
let loaded = ui.gallery.model.n_items() as usize;
|
||||||
ui.gallery.subtitle.set_visible(count > 0);
|
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".
|
// 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::Video) => ("video", "videos"),
|
||||||
Some(PhotoKind::Raw) => ("raw photo", "raw photos"),
|
Some(PhotoKind::Raw) => ("raw photo", "raw photos"),
|
||||||
_ => ("photo", "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}"),
|
1 => format!("1 {one}"),
|
||||||
n => format!("{n} {many}"),
|
n => format!("{} {many}", thousands(n)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1272,6 +1341,9 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
|||||||
if ui.gallery.loading.get() {
|
if ui.gallery.loading.get() {
|
||||||
return;
|
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 {
|
if !append {
|
||||||
// Fresh load: clear the timeline and show Loading until the first page lands.
|
// Fresh load: clear the timeline and show Loading until the first page lands.
|
||||||
ui.gallery.model.remove_all();
|
ui.gallery.model.remove_all();
|
||||||
@@ -1284,8 +1356,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
|
// 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
|
// load — a jump *to* a month sets a range and reloads, and refreshing the
|
||||||
// dropdown then would fight the selection the user just made.
|
// dropdown then would fight the selection the user just made. An album
|
||||||
if ui.gallery.range.get().is_none() {
|
// has no date jump at all.
|
||||||
|
if album.is_none() && ui.gallery.range.get().is_none() {
|
||||||
refresh_photo_months(ui);
|
refresh_photo_months(ui);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1294,15 +1367,20 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
|||||||
ui.gallery.more.set_sensitive(false);
|
ui.gallery.more.set_sensitive(false);
|
||||||
|
|
||||||
ui.busy_begin();
|
ui.busy_begin();
|
||||||
let rx = spawn_request(
|
let request = match album {
|
||||||
ui.dirs.control_socket(),
|
Some(uid) => Request::AlbumPhotos {
|
||||||
Request::PhotosTimeline {
|
uid,
|
||||||
|
offset,
|
||||||
|
limit: PHOTOS_PAGE,
|
||||||
|
},
|
||||||
|
None => Request::PhotosTimeline {
|
||||||
offset,
|
offset,
|
||||||
limit: PHOTOS_PAGE,
|
limit: PHOTOS_PAGE,
|
||||||
kind: ui.gallery.kind.get(),
|
kind: ui.gallery.kind.get(),
|
||||||
range: ui.gallery.range.get(),
|
range: ui.gallery.range.get(),
|
||||||
},
|
},
|
||||||
);
|
};
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), request);
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let result = rx.recv().await;
|
let result = rx.recv().await;
|
||||||
@@ -1329,20 +1407,13 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
|||||||
if let Some(counts) = counts {
|
if let Some(counts) = counts {
|
||||||
update_gallery_tabs(&ui, counts);
|
update_gallery_tabs(&ui, counts);
|
||||||
}
|
}
|
||||||
// Take the daemon's word on ratios and thumbnail verdicts before
|
// Take the daemon's word on which photos can never have a
|
||||||
// anything is laid out: it persists both, so the very first frame
|
// thumbnail, so their tiles show a placeholder from the first
|
||||||
// justifies its rows correctly instead of guessing RATIO_UNKNOWN
|
// frame instead of queueing a request that can only fail.
|
||||||
// and reflowing as the images land.
|
|
||||||
{
|
{
|
||||||
let mut ratios = ui.gallery.photo_ratio.borrow_mut();
|
|
||||||
let mut nothumb = ui.gallery.photo_nothumb.borrow_mut();
|
let mut nothumb = ui.gallery.photo_nothumb.borrow_mut();
|
||||||
for item in &items {
|
for item in items.iter().filter(|item| item.no_thumb) {
|
||||||
if let Some(ratio) = item.ratio {
|
nothumb.insert(item.uid.clone());
|
||||||
ratios.insert(item.uid.clone(), ratio);
|
|
||||||
}
|
|
||||||
if item.no_thumb {
|
|
||||||
nothumb.insert(item.uid.clone());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for item in &items {
|
for item in &items {
|
||||||
@@ -1350,13 +1421,15 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
|||||||
}
|
}
|
||||||
repaint_gallery(&ui);
|
repaint_gallery(&ui);
|
||||||
if ui.gallery.model.n_items() == 0 {
|
if ui.gallery.model.n_items() == 0 {
|
||||||
gallery_status(
|
let (title, description) = if ui.gallery.album.borrow().is_some() {
|
||||||
&ui,
|
("Empty album", "This album has no photos in it.")
|
||||||
"image-x-generic-symbolic",
|
} else {
|
||||||
"No photos yet",
|
(
|
||||||
"Photos you upload to Proton Drive appear here.",
|
"No photos yet",
|
||||||
false,
|
"Photos you upload to Proton Drive appear here.",
|
||||||
);
|
)
|
||||||
|
};
|
||||||
|
gallery_status(&ui, "image-x-generic-symbolic", title, description, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.gallery.content.set_visible_child_name("timeline");
|
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)
|
.title(&entry.name)
|
||||||
.activatable(true)
|
.activatable(true)
|
||||||
.build();
|
.build();
|
||||||
if !entry.is_dir {
|
let subtitle = shared_row_subtitle(entry);
|
||||||
row.set_subtitle(&human_bytes(entry.size));
|
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 {
|
row.add_prefix(>k4::Image::from_icon_name(if entry.is_dir {
|
||||||
"folder-symbolic"
|
"folder-symbolic"
|
||||||
@@ -325,6 +332,43 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
|
|||||||
row
|
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
|
/// 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
|
/// 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.
|
/// 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);
|
row.add_suffix(&open);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manage opens the per-node Share dialog — only when the node's path is
|
// Manage opens the per-node Share dialog. It used to be offered only for
|
||||||
// known (it has been browsed to this session); the dialog is path-keyed.
|
// an item whose path the daemon could resolve; the dialog now addresses a
|
||||||
if !item.path.is_empty() {
|
// pathless node by uid, so every shared item can be managed from here.
|
||||||
let manage = gtk4::Button::builder()
|
let manage = gtk4::Button::builder()
|
||||||
.label("Manage")
|
.label("Manage")
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
manage.add_css_class("flat");
|
manage.add_css_class("flat");
|
||||||
let ui_manage = ui.clone();
|
let ui_manage = ui.clone();
|
||||||
let entry = shared_item_as_entry(item);
|
let entry = shared_item_as_entry(item);
|
||||||
manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry));
|
manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry));
|
||||||
row.add_suffix(&manage);
|
row.add_suffix(&manage);
|
||||||
}
|
|
||||||
|
|
||||||
ui.shared_by_me.group.add(&row);
|
ui.shared_by_me.group.add(&row);
|
||||||
rows.push(row.upcast());
|
rows.push(row.upcast());
|
||||||
@@ -295,5 +294,7 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
|||||||
cached: false,
|
cached: false,
|
||||||
uid: item.uid.clone(),
|
uid: item.uid.clone(),
|
||||||
path: item.path.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
|
/// Cache-budget editor (GiB). Populated once from config; user edits drive a
|
||||||
/// `SetCacheBudget` round-trip. Guarded by [`Self::settings_suppress`].
|
/// `SetCacheBudget` round-trip. Guarded by [`Self::settings_suppress`].
|
||||||
pub(crate) budget_row: adw::SpinRow,
|
pub(crate) budget_row: adw::SpinRow,
|
||||||
/// Shows the active mountpoint in its subtitle; updated when the user picks a
|
/// Shows where the primary mount lives; the folder itself is managed on the
|
||||||
/// new folder.
|
/// Locations page, which owns every local path.
|
||||||
pub(crate) mountpoint_row: adw::ActionRow,
|
pub(crate) mountpoint_row: adw::ActionRow,
|
||||||
/// Set while a settings widget is being populated programmatically, so its
|
/// Set while a settings widget is being populated programmatically, so its
|
||||||
/// change handler skips the IPC/systemd side effect.
|
/// change handler skips the IPC/systemd side effect.
|
||||||
@@ -100,7 +100,8 @@ pub(crate) struct MainWidgets {
|
|||||||
pub(crate) budget_row: adw::SpinRow,
|
pub(crate) budget_row: adw::SpinRow,
|
||||||
/// Purges all unpinned cached content.
|
/// Purges all unpinned cached content.
|
||||||
pub(crate) purge_button: gtk4::Button,
|
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_row: adw::ActionRow,
|
||||||
pub(crate) mountpoint_button: gtk4::Button,
|
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.")
|
.subtitle("Mount Proton Drive automatically when you log in.")
|
||||||
.build();
|
.build();
|
||||||
system_group.add(&autostart_row);
|
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()
|
let mountpoint_row = adw::ActionRow::builder()
|
||||||
.title("Mountpoint")
|
.title("Mountpoint")
|
||||||
.subtitle("—")
|
.subtitle("—")
|
||||||
.build();
|
.build();
|
||||||
let mountpoint_button = gtk4::Button::builder()
|
let mountpoint_button = gtk4::Button::builder()
|
||||||
.label("Change")
|
.label("Locations")
|
||||||
|
.tooltip_text("Manage this computer's Proton Drive locations")
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
mountpoint_button.add_css_class("flat");
|
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
|
// Mountpoint: the chooser lives on the Locations page, next to every other
|
||||||
// the change takes effect (the daemon reads the path on mount).
|
// local path; this button is the way there.
|
||||||
let ui_mp = ui.clone();
|
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,
|
/// 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;
|
return;
|
||||||
}
|
}
|
||||||
ui.status.mountpoint_row.set_subtitle(&path_str);
|
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.
|
// The daemon only reads the mountpoint at mount time, so offer a restart.
|
||||||
let confirm = adw::AlertDialog::builder()
|
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.
|
// they are on screen. Every other page loads on navigation only.
|
||||||
match ui.stack.visible_child_name().as_deref() {
|
match ui.stack.visible_child_name().as_deref() {
|
||||||
Some("main") => refresh_quota(ui),
|
Some("main") => refresh_quota(ui),
|
||||||
Some("devices") => refresh_sync_folders(ui),
|
Some("locations") => refresh_locations(ui),
|
||||||
Some("activity") => refresh_activity(ui),
|
Some("activity") => refresh_activity(ui),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
/// 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
|
/// handlers can rebuild the people and link sections after each change without
|
||||||
/// tearing the whole dialog down.
|
/// tearing the whole dialog down.
|
||||||
pub(crate) struct ShareDialog {
|
pub(crate) struct ShareDialog {
|
||||||
pub(crate) ui: Rc<Ui>,
|
pub(crate) ui: Rc<Ui>,
|
||||||
/// The node's mountpoint-relative path — how every request addresses it.
|
/// How every request from this dialog addresses the node.
|
||||||
pub(crate) rel: String,
|
pub(crate) target: ShareTarget,
|
||||||
pub(crate) people: adw::PreferencesGroup,
|
pub(crate) people: adw::PreferencesGroup,
|
||||||
pub(crate) link_group: adw::PreferencesGroup,
|
pub(crate) link_group: adw::PreferencesGroup,
|
||||||
pub(crate) people_rows: RefCell<Vec<gtk4::Widget>>,
|
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.");
|
toast_error(ui, "Can't share", "Proton Drive isn't connected.");
|
||||||
return;
|
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 toolbar = adw::ToolbarView::new();
|
||||||
let header = adw::HeaderBar::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 {
|
let state = Rc::new(ShareDialog {
|
||||||
ui: ui.clone(),
|
ui: ui.clone(),
|
||||||
rel,
|
target,
|
||||||
people,
|
people,
|
||||||
link_group,
|
link_group,
|
||||||
people_rows: RefCell::new(Vec::new()),
|
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();
|
let msg_clear = message_row.clone();
|
||||||
share_dialog_op(
|
share_dialog_op(
|
||||||
&state_inv,
|
&state_inv,
|
||||||
Request::ShareNode {
|
state_inv.target.invite(emails, role, message),
|
||||||
path: state_inv.rel.clone(),
|
|
||||||
emails,
|
|
||||||
role,
|
|
||||||
message,
|
|
||||||
},
|
|
||||||
"Invitations sent",
|
"Invitations sent",
|
||||||
"Couldn't send invitations",
|
"Couldn't send invitations",
|
||||||
Some(Box::new(move || {
|
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.
|
/// Re-fetch the node's share and rebuild the people + public-link sections.
|
||||||
pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
|
pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
|
||||||
let rx = spawn_request(
|
let rx = spawn_request(state.ui.dirs.control_socket(), state.target.list());
|
||||||
state.ui.dirs.control_socket(),
|
|
||||||
Request::ListShare {
|
|
||||||
path: state.rel.clone(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
match rx.recv().await {
|
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| {
|
drop.connect_selected_notify(move |d| {
|
||||||
share_dialog_op(
|
share_dialog_op(
|
||||||
&state_role,
|
&state_role,
|
||||||
Request::UpdateShareRole {
|
state_role.target.update_role(
|
||||||
path: state_role.rel.clone(),
|
id.clone(),
|
||||||
id: id.clone(),
|
|
||||||
kind,
|
kind,
|
||||||
role: role_index_to_wire(d.selected()).to_string(),
|
role_index_to_wire(d.selected()).to_string(),
|
||||||
},
|
),
|
||||||
"Role updated",
|
"Role updated",
|
||||||
"Couldn't update the role",
|
"Couldn't update the role",
|
||||||
None,
|
None,
|
||||||
@@ -265,11 +364,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
|||||||
remove.connect_clicked(move |_| {
|
remove.connect_clicked(move |_| {
|
||||||
share_dialog_op(
|
share_dialog_op(
|
||||||
&state_rm,
|
&state_rm,
|
||||||
Request::RemoveShareEntry {
|
state_rm.target.remove_entry(id.clone(), kind),
|
||||||
path: state_rm.rel.clone(),
|
|
||||||
id: id.clone(),
|
|
||||||
kind,
|
|
||||||
},
|
|
||||||
"Access removed",
|
"Access removed",
|
||||||
"Couldn't remove access",
|
"Couldn't remove access",
|
||||||
None,
|
None,
|
||||||
@@ -332,10 +427,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
|||||||
remove.connect_clicked(move |_| {
|
remove.connect_clicked(move |_| {
|
||||||
share_dialog_op(
|
share_dialog_op(
|
||||||
&state_rm,
|
&state_rm,
|
||||||
Request::RemovePublicLink {
|
state_rm.target.remove_link(id.clone()),
|
||||||
path: state_rm.rel.clone(),
|
|
||||||
id: id.clone(),
|
|
||||||
},
|
|
||||||
"Public link removed",
|
"Public link removed",
|
||||||
"Couldn't remove the link",
|
"Couldn't remove the link",
|
||||||
None,
|
None,
|
||||||
@@ -407,12 +499,7 @@ pub(crate) fn share_dialog_create_link(
|
|||||||
) {
|
) {
|
||||||
let rx = spawn_request(
|
let rx = spawn_request(
|
||||||
state.ui.dirs.control_socket(),
|
state.ui.dirs.control_socket(),
|
||||||
Request::CreatePublicLink {
|
state.target.create_link(role, password),
|
||||||
path: state.rel.clone(),
|
|
||||||
role,
|
|
||||||
password,
|
|
||||||
expires: None,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
|
|||||||
@@ -76,6 +76,31 @@ sequenceDiagram
|
|||||||
FUSE-->>User: returns child inode metadata (attributes, TTL)
|
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
|
## 3. Read Path & Block Caching Pipeline
|
||||||
|
|||||||
+130
-1
@@ -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)
|
## B1 — FUSE rename loses the file (data loss)
|
||||||
|
|
||||||
**Status:** Fixed (verified 2026-07-20)
|
**Status:** Fixed (verified 2026-07-20)
|
||||||
@@ -2105,7 +2232,9 @@ duplicates, renamed folders, multiple devices, and byte-identical restoration.
|
|||||||
|
|
||||||
## B68 — Profile Backup Cannot Be Written to the Device Root
|
## 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
|
**Found:** complete managed-live mode matrix against the 1.0.0 release binary
|
||||||
**Where:** `crates/pdfs-fuse/src/profile.rs`, profile backup destination
|
**Where:** `crates/pdfs-fuse/src/profile.rs`, profile backup destination
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -58,7 +58,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.
|
* **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.
|
* **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.
|
* **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 +78,7 @@ Derived from: [`TESTING.md`](TESTING.md) and [`RECOVERY.md`](RECOVERY.md)
|
|||||||
3. Wipe the local state directory and SQLite cache.
|
3. Wipe the local state directory and SQLite cache.
|
||||||
4. Restart the daemon.
|
4. Restart the daemon.
|
||||||
5. Run `pdfs sync restore` and assert the recovered filesystem matches the source byte-for-byte.
|
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.
|
* **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.
|
* **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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
narrowing it produced a smaller reproduction that has its own case now. Expect a
|
||||||
red run against a real mount until it is fixed.
|
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
|
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
|
queue to drain before reading. Reading straight back through the mount proves
|
||||||
nothing: the local attribute and content caches will happily return the bytes
|
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
|
contains the supplied text (for example `PDFS_ACCEPTANCE_ONLY=namespace`). A
|
||||||
release acceptance run must leave it unset so the complete contract executes.
|
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.
|
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
|
The first path must be the FUSE mount under test; later paths may be another
|
||||||
FUSE mount or a normal local mirror filesystem:
|
FUSE mount or a normal local mirror filesystem:
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
# Build + install: cd packaging && makepkg -fi
|
# Build + install: cd packaging && makepkg -fi
|
||||||
|
|
||||||
pkgname=proton-drive-linux
|
pkgname=proton-drive-linux
|
||||||
pkgver=1.2.0
|
pkgver=1.3.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
||||||
arch=('x86_64')
|
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())
|
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]:
|
def _conflict_copies(root: Path) -> list[str]:
|
||||||
return sorted(name for name in os.listdir(root) if "(sync-conflict" in name)
|
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 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 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 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,)),
|
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]:
|
def select_cases(kind: str) -> list[Case]:
|
||||||
selected = os.environ.get("PDFS_ACCEPTANCE_ONLY")
|
selected = os.environ.get("PDFS_ACCEPTANCE_ONLY")
|
||||||
cases = [case for case in TESTS if kind in case.kinds]
|
cases = [case for case in TESTS if kind in case.kinds]
|
||||||
if selected:
|
if not selected:
|
||||||
cases = [case for case in cases if selected.lower() in case.name.lower()]
|
return cases
|
||||||
check(bool(cases), f"PDFS_ACCEPTANCE_ONLY={selected!r} matched no tests for {kind}")
|
needle = selected.lower()
|
||||||
return cases
|
# 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:
|
def reap_stale_roots(parent: Path, max_age: int) -> None:
|
||||||
|
|||||||
Reference in new issue
Block a user