Compare commits

...
14 Commits
Author SHA1 Message Date
nvrl 5109427c32 chore(release): 2.2.1 2026-09-24 19:49:42 +02:00
nvrl e2c4dac8df feat(gui): legal links and disclaimer in the About dialog
Add a copyright line with contact address, links to the source code and
to Proton's terms of service and privacy policy, a Legal section with the
full non-affiliation, trademark and no-warranty disclaimer, and a license
credit for proton-sdk-rs. Translated into every shipped language.
2026-09-24 19:49:42 +02:00
nvrl ebeb9cdbef chore(aur): refresh -git pkgver in update.sh and bump to 2.2.0.r1.g82e9654
makepkg --printsrcinfo does not run pkgver(), so the -git .SRCINFO kept
the version from when the package was created and push.sh never had a
change to publish. update.sh now runs makepkg -od --noprepare first so
pkgver() writes the current git describe version into the PKGBUILD.
2026-09-24 19:49:42 +02:00
Nils Pukropp 82e96543ac chore(aur): update packages to 2.2.0 2026-09-23 18:48:21 +00:00
nvrl 96c77e96cb chore(release): 2.2.0 2026-09-23 20:29:15 +02:00
nvrl 90e110e893 feat(fuse,core): thumbnails for videos and for our own uploads
Videos without a server thumbnail fell through to the local fallback,
which downloaded the whole file, failed to decode it as an image and
wrote the video off for good. Grab one frame with ffmpeg instead, a
second in or from the start for shorter clips. Probe for ffmpeg before
downloading, bound every run, and skip videos over 256 MiB.
Migration V32 gives the videos written off so far another try.

Those videos had no server thumbnail because our uploads never sent one.
Every upload path (bulk upload, drained mount writes, conflict copies,
new revisions and folder sync) now attaches a thumbnail rendered from
the local file, kept under the 60 KiB Proton's own clients use. Files
that are not images or videos, or cannot be decoded, upload bare as
before; a .ts file only counts as video with an MPEG-TS sync byte.

ffmpeg is a new optional dependency in the PKGBUILDs.
2026-09-23 20:29:15 +02:00
nvrl 0b7236bd4a fix(gui): steady the photo gallery and fill the details panel
The month scrubber sat under the overlay scrollbar, which widens under
the pointer, so the two collided. Keep the scrubber clear of it.

The gallery jumped when thumbnails loaded, on zoom and on tab switches.
Relayout anchored on the first bound row, but ListView keeps off-screen
rows bound with stale allocations, and scroll_to on an off-screen row
aligns it to the top. Anchor on the first mapped row that is actually
visible instead.

The viewer's details panel opened empty: the title's hexpand propagated
up to the revealer, which then took all the width. Set hexpand on the
revealer explicitly.

EXIF details read like a camera: Make, Model and Lens lose their EXIF
quotes, a model that already names its maker is not repeated, shutter
speeds are rounded (1/19 s), and ISO is labelled.
2026-09-23 20:29:15 +02:00
Nils Pukropp 2fa5eaa75b chore(aur): update packages to 2.1.1 2026-09-23 16:44:58 +00:00
Nils Pukropp dc9ea9f541 chore(aur): update packages to 2.1.0 2026-09-23 16:29:57 +00:00
Nils Pukropp b13d9d9f38 chore(aur): update packages to 2.1.1 2026-09-23 16:17:09 +00:00
nvrl b696adefc2 chore(release): 2.1.1 2026-09-23 18:00:03 +02:00
nvrl 519ec1af07 fix(fuse): stop a superseded upload from spinning its drain thread
A write that lands while a revision is on the wire cancels that upload
through the flag CountingReader checks before each read. The cancel was
reported as ErrorKind::Interrupted, which every read loop retries,
including the SDK's read_full_block. The flag never clears, so the drain
thread spun at 100% CPU forever and the transfer stayed listed as
uploading at 0 bytes until the daemon restarted. A growing browser
download (.crdownload) triggers it naturally.

Report the cancel as ErrorKind::Other so the SDK fails the upload;
drain_revision already treats a cancelled upload as abandoned, not
failed. Tracked as B98.
2026-09-23 17:58:06 +02:00
Nils Pukropp 4820ea1236 chore(aur): update packages to 2.1.0 2026-09-23 09:23:32 +00:00
nvrl a9d6e0280d ci(aur): publish the AUR packages from CI
A release tag now updates and pushes all three AUR packages once the
GitHub release exists, then commits the refreshed recipes back to main.
A push to main that changes the -git recipe publishes that package
alone; plain upstream commits need no AUR push because its pkgver()
follows git at build time.

Needs the AUR_SSH_PRIVATE_KEY repository secret.
2026-09-23 11:02:30 +02:00
34 changed files with 2961 additions and 1720 deletions

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
name: AUR
on:
# The -git package computes its pkgver from git at build time, so a new commit
# upstream needs no AUR push. Only a changed -git recipe is published.
push:
branches: [main]
paths:
- 'packaging/aur/proton-drive-for-linux-git/**'
# Called by release.yml once the GitHub release and its assets exist.
workflow_call:
inputs:
version:
required: true
type: string
secrets:
AUR_SSH_PRIVATE_KEY:
required: true
workflow_dispatch:
inputs:
version:
description: 'Release to publish, such as 2.1.0. Leave empty to publish only the -git package.'
required: false
type: string
permissions:
contents: write
concurrency:
group: aur
cancel-in-progress: false
jobs:
publish:
name: Publish AUR packages
runs-on: ubuntu-24.04
container: archlinux:base-devel
env:
# makepkg refuses to run as root, so the packaging steps run as `builder`.
HOME: /home/builder
VERSION: ${{ inputs.version }}
steps:
- name: Install packaging tools
run: pacman -Syu --noconfirm --needed git openssh pacman-contrib
- name: Checkout repository
uses: actions/checkout@v4
with:
# The updated recipes are committed back to main, not to the tag.
ref: main
- name: Create build user
run: |
useradd --create-home builder
chown -R builder: "$GITHUB_WORKSPACE"
- name: Configure AUR access
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
run: |
install -d -m700 -o builder -g builder "$HOME/.ssh"
printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$HOME/.ssh/aur"
cat > "$HOME/.ssh/config" <<'EOF'
Host aur.archlinux.org
IdentityFile ~/.ssh/aur
User aur
EOF
# Pin the host key to the fingerprint published on aur.archlinux.org.
ssh-keyscan -t ed25519 aur.archlinux.org > "$HOME/.ssh/known_hosts"
ssh-keygen -lf "$HOME/.ssh/known_hosts" | grep -q 'SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4'
chown -R builder: "$HOME/.ssh"
chmod 600 "$HOME/.ssh/aur"
runuser -u builder -- git config --global user.name 'Nils Pukropp'
runuser -u builder -- git config --global user.email 'contact@narl.io'
- name: Update release packages
if: env.VERSION != ''
run: runuser -u builder -- packaging/aur/update.sh "${VERSION#v}"
- name: Refresh the -git metadata
if: env.VERSION == ''
working-directory: packaging/aur/proton-drive-for-linux-git
run: runuser -u builder -- sh -c 'makepkg --printsrcinfo > .SRCINFO'
- name: Publish to the AUR
run: |
if [ -n "$VERSION" ]; then
runuser -u builder -- packaging/aur/push.sh
else
runuser -u builder -- packaging/aur/push.sh proton-drive-for-linux-git
fi
- name: Commit the updated recipes to main
if: env.VERSION != ''
run: |
runuser -u builder -- git add packaging/aur
if ! runuser -u builder -- git diff --cached --quiet; then
runuser -u builder -- git commit -m "chore(aur): update packages to ${VERSION#v}"
runuser -u builder -- git push origin HEAD:main
fi
+10
View File
@@ -202,3 +202,13 @@ jobs:
prerelease: false prerelease: false
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
aur:
name: Publish AUR packages
needs: build-release
if: startsWith(github.ref, 'refs/tags/')
uses: ./.github/workflows/aur.yml
with:
version: ${{ github.ref_name }}
secrets:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
Generated
+4 -4
View File
@@ -3449,7 +3449,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "pdfs-cli" name = "pdfs-cli"
version = "2.1.0" version = "2.2.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -3464,7 +3464,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-core" name = "pdfs-core"
version = "2.1.0" version = "2.2.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dbus-secret-service-keyring-store", "dbus-secret-service-keyring-store",
@@ -3488,7 +3488,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-fuse" name = "pdfs-fuse"
version = "2.1.0" version = "2.2.1"
dependencies = [ dependencies = [
"base64 0.23.1", "base64 0.23.1",
"fuser", "fuser",
@@ -3508,7 +3508,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-gui" name = "pdfs-gui"
version = "2.1.0" version = "2.2.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
+1 -1
View File
@@ -3,7 +3,7 @@ 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 = "2.1.0" version = "2.2.1"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"] authors = ["Nils Pukropp <contact@narl.io>"]
+22 -1
View File
@@ -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 = 31; pub(super) const SCHEMA_VERSION: i64 = 32;
impl Db { impl Db {
pub(super) fn migrate(&self) -> Result<()> { pub(super) fn migrate(&self) -> Result<()> {
@@ -270,6 +270,20 @@ impl Db {
tx.execute_batch(MIGRATION_V31)?; tx.execute_batch(MIGRATION_V31)?;
} }
} }
if current < 32 {
// Data only, so safe to repeat; each table is guarded for a very
// old fixture that never had it.
for table in ["photos", "album_photos"] {
let has_table: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
[table],
|row| row.get::<_, i64>(0),
)? > 0;
if has_table {
tx.execute(&MIGRATION_V32.replace("{table}", table), [])?;
}
}
}
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",
@@ -952,3 +966,10 @@ ALTER TABLE photos ADD COLUMN resolved_at INTEGER;
CREATE INDEX IF NOT EXISTS idx_photos_unresolved ON photos(resolved_at); CREATE INDEX IF NOT EXISTS idx_photos_unresolved ON photos(resolved_at);
UPDATE photos SET resolved_at = strftime('%s','now') WHERE name IS NOT NULL; UPDATE photos SET resolved_at = strftime('%s','now') WHERE name IS NOT NULL;
"; ";
/// V32: give videos written off as un-thumbnailable another chance.
///
/// Before local video thumbnails, the fallback fed a video's bytes to the image
/// decoder, which could only fail, and that failure was recorded as the
/// permanent "no thumbnail" verdict. Run once per table in `{table}`.
const MIGRATION_V32: &str = "UPDATE {table} SET thumb_state = 0 WHERE kind = 1 AND thumb_state = 2";
+42
View File
@@ -4235,6 +4235,48 @@ fn the_servers_photo_relation_groups_a_live_photo() {
/// A database written before grouping existed opens, keeps its timeline, and /// A database written before grouping existed opens, keeps its timeline, and
/// shows every photo — a row with no `group_key` is its own group, which is how /// shows every photo — a row with no `group_key` is its own group, which is how
/// the gallery behaved before the column. /// the gallery behaved before the column.
#[test]
fn migration_v32_retries_videos_written_off_as_unthumbnailable() {
let path = std::env::temp_dir().join(format!(
"pdfs-db-v31-fixture-{}-{}.db",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
{
let db = Db::open(&path).unwrap();
db.photos_replace(&[
TimelineRow {
name: Some("VID-20260804-WA0023.mp4".into()),
..TimelineRow::new("video", 300)
},
TimelineRow {
name: Some("broken.jpg".into()),
..TimelineRow::new("photo", 200)
},
])
.unwrap();
db.photo_set_thumb("video", THUMB_NONE, None).unwrap();
db.photo_set_thumb("photo", THUMB_NONE, None).unwrap();
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch("UPDATE sync_state SET value = '31' WHERE key = 'schema_version';")
.unwrap();
}
let db = Db::open(&path).unwrap();
let state = |uid: &str| db.photos_by_uid(&[uid.into()]).unwrap()[0].thumb_state;
assert_eq!(state("video"), THUMB_UNKNOWN, "the video is tried again");
assert_eq!(
state("photo"),
THUMB_NONE,
"an undecodable photo stays written off"
);
drop(db);
let _ = std::fs::remove_file(&path);
}
#[test] #[test]
fn migration_v30_leaves_a_v29_timeline_intact() { fn migration_v30_leaves_a_v29_timeline_intact() {
let path = std::env::temp_dir().join(format!( let path = std::env::temp_dir().join(format!(
+6 -3
View File
@@ -945,6 +945,7 @@ impl Core {
if !meta.complete { if !meta.complete {
return Err("queued create holds an incomplete blob".into()); return Err("queued create holds an incomplete blob".into());
} }
let thumbnails = self.upload_thumbnails(Path::new(blob), name);
let guard = self let guard = self
.transfers .transfers
.begin(name, op.uid.clone(), TransferDirection::Upload, meta.len); .begin(name, op.uid.clone(), TransferDirection::Upload, meta.len);
@@ -955,7 +956,7 @@ impl Core {
media_type_for(name), media_type_for(name),
reader, reader,
meta.len as i64, meta.len as i64,
Vec::new(), thumbnails,
None, None,
false, false,
))?; ))?;
@@ -1145,6 +1146,7 @@ impl Core {
} }
let alt = conflict_name(&name, now_secs()); let alt = conflict_name(&name, now_secs());
let thumbnails = self.upload_thumbnails(blob, &alt);
let guard = let guard =
self.transfers self.transfers
.begin(&alt, meta.uid.clone(), TransferDirection::Upload, meta.len); .begin(&alt, meta.uid.clone(), TransferDirection::Upload, meta.len);
@@ -1155,7 +1157,7 @@ impl Core {
media_type_for(&alt), media_type_for(&alt),
reader, reader,
meta.len as i64, meta.len as i64,
Vec::new(), thumbnails,
None, None,
false, false,
))?; ))?;
@@ -1438,6 +1440,7 @@ impl Core {
} }
let name = self.node_name(&uid); let name = self.node_name(&uid);
let thumbnails = self.upload_thumbnails(&blob, &name);
let guard = let guard =
self.transfers self.transfers
.begin(&name, meta.uid.clone(), TransferDirection::Upload, meta.len); .begin(&name, meta.uid.clone(), TransferDirection::Upload, meta.len);
@@ -1451,7 +1454,7 @@ impl Core {
&uid, &uid,
reader, reader,
meta.len as i64, meta.len as i64,
Vec::new(), thumbnails,
None, None,
)); ));
let took = started.elapsed(); let took = started.elapsed();
+484 -58
View File
@@ -12,8 +12,8 @@ use std::io::{Read as _, Write as _};
use std::os::unix::fs::OpenOptionsExt as _; use std::os::unix::fs::OpenOptionsExt as _;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::sync::Once;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Once, OnceLock};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use base64::Engine as _; use base64::Engine as _;
@@ -25,8 +25,8 @@ use pdfs_core::db::{self, StoredPhoto};
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 proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, ThumbnailType}; use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, Thumbnail, ThumbnailType};
use tracing::{info, warn}; use tracing::{debug, info, warn};
use super::{ use super::{
Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS, TIMELINE_ENRICH_CHUNK, TIMELINE_TTL, WriteAuthority, Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS, TIMELINE_ENRICH_CHUNK, TIMELINE_TTL, WriteAuthority,
@@ -55,6 +55,20 @@ const THUMB_CANCEL_POLL: Duration = Duration::from_millis(100);
const EXIFTOOL_TIMEOUT: Duration = Duration::from_secs(20); const EXIFTOOL_TIMEOUT: Duration = Duration::from_secs(20);
static RAW_TEMP_NONCE: AtomicU64 = AtomicU64::new(0); static RAW_TEMP_NONCE: AtomicU64 = AtomicU64::new(0);
static EXIFTOOL_MISSING_WARNING: Once = Once::new(); static EXIFTOOL_MISSING_WARNING: Once = Once::new();
static FFMPEG_MISSING_WARNING: Once = Once::new();
/// Largest video downloaded whole to grab one frame for its thumbnail. The
/// download lands in memory, so a long 4K recording is left without one.
const VIDEO_THUMB_MAX_BYTES: u64 = 256 * 1024 * 1024;
/// A single frame is seconds of work even for 4K; this only stops a wedged
/// ffmpeg from holding a thumbnail permit.
const FFMPEG_TIMEOUT: Duration = Duration::from_secs(30);
/// Largest thumbnail attached to an upload. Proton's own clients hold theirs to
/// this, stepping the JPEG quality down until it fits.
const UPLOAD_THUMB_MAX_BYTES: usize = 60 * 1024;
/// JPEG qualities tried, in order, for an upload thumbnail over that size.
const UPLOAD_THUMB_QUALITIES: [u8; 4] = [65, 50, 35, 20];
/// Largest still image read whole to render the thumbnail its upload carries.
const UPLOAD_IMAGE_MAX_BYTES: u64 = 256 * 1024 * 1024;
/// The aspect ratio (w/h) of an encoded image, read from its header alone — no /// The aspect ratio (w/h) of an encoded image, read from its header alone — no
/// pixels are decoded. `None` when the format is unknown or the header is torn. /// pixels are decoded. `None` when the format is unknown or the header is torn.
@@ -90,7 +104,8 @@ struct RawPreviewUnavailable;
/// the photo will never have a thumbnail, and only that verdict is persisted. /// the photo will never have a thumbnail, and only that verdict is persisted.
enum ThumbAttempt { enum ThumbAttempt {
Made(GeneratedThumb), Made(GeneratedThumb),
/// Decoded nothing — a format this build has no decoder for. Permanent. /// Decoded nothing — a format this build has no decoder for, or a video
/// too large to fetch for one frame. Permanent.
Undecodable, Undecodable,
/// The photo couldn't be downloaded. Transient: try again next time. /// The photo couldn't be downloaded. Transient: try again next time.
Unavailable, Unavailable,
@@ -196,6 +211,11 @@ fn scale_thumbnail_with_exiftool(
} }
None => return ScaleAttempt::Undecodable, None => return ScaleAttempt::Undecodable,
}; };
encode_thumb(&image)
}
/// Shrink a decoded image to a [`THUMB_EDGE`] JPEG, keeping its aspect ratio.
fn encode_thumb(image: &image::DynamicImage) -> ScaleAttempt {
let (width, height) = (image.width(), image.height()); let (width, height) = (image.width(), image.height());
if width == 0 || height == 0 { if width == 0 || height == 0 {
return ScaleAttempt::Undecodable; return ScaleAttempt::Undecodable;
@@ -268,9 +288,69 @@ impl Drop for RawTempFile {
} }
} }
/// Run one bounded exiftool JSON query while draining stdout concurrently. The /// How a bounded helper run failed.
/// latter matters for full-size `JpgFromRaw` fallbacks, whose base64 can exceed a #[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// pipe buffer long before the child exits. enum ToolError {
/// The helper isn't installed.
Missing,
/// It couldn't be started or read, or overran its budget and was killed.
Unavailable,
/// It ran and exited non-zero.
Failed,
}
/// Run a helper to completion within `timeout` and return its stdout, drained
/// concurrently. The latter matters for exiftool's full-size `JpgFromRaw`
/// fallbacks and for ffmpeg's frames, either of which can exceed a pipe buffer
/// long before the child exits.
fn run_bounded(mut command: Command, timeout: Duration) -> Result<Vec<u8>, ToolError> {
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = command.spawn().map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
ToolError::Missing
} else {
ToolError::Unavailable
}
})?;
let Some(mut stdout) = child.stdout.take() else {
let _ = child.kill();
let _ = child.wait();
return Err(ToolError::Unavailable);
};
let reader = std::thread::spawn(move || {
let mut output = Vec::new();
stdout.read_to_end(&mut output).map(|_| output)
});
let started = Instant::now();
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break Some(status.success()),
Ok(None) if started.elapsed() < timeout => {
std::thread::sleep(Duration::from_millis(10));
}
Ok(None) | Err(_) => {
let _ = child.kill();
let _ = child.wait();
break None;
}
}
};
let output = reader
.join()
.ok()
.and_then(Result::ok)
.ok_or(ToolError::Unavailable)?;
match status {
Some(true) => Ok(output),
Some(false) => Err(ToolError::Failed),
None => Err(ToolError::Unavailable),
}
}
/// Run one bounded exiftool JSON query.
fn exiftool_query( fn exiftool_query(
exiftool: &OsStr, exiftool: &OsStr,
path: &Path, path: &Path,
@@ -280,58 +360,172 @@ fn exiftool_query(
let mut command = Command::new(exiftool); let mut command = Command::new(exiftool);
command.args(["-j", "-b", "-n"]); command.args(["-j", "-b", "-n"]);
command.args(tags); command.args(tags);
command command.arg(path);
.arg(path) let output = run_bounded(command, timeout).map_err(|error| {
.stdout(Stdio::piped()) if error == ToolError::Missing {
.stderr(Stdio::null()); EXIFTOOL_MISSING_WARNING.call_once(|| {
let mut child = match command.spawn() { warn!(
Ok(child) => child, "exiftool is not installed; camera RAW thumbnails are unavailable \
Err(error) => { (Debian/Ubuntu: libimage-exiftool-perl)"
if error.kind() == std::io::ErrorKind::NotFound { );
EXIFTOOL_MISSING_WARNING.call_once(|| { });
warn!(
"exiftool is not installed; camera RAW thumbnails are unavailable \
(Debian/Ubuntu: libimage-exiftool-perl)"
);
});
}
return Err(RawPreviewUnavailable);
} }
}; RawPreviewUnavailable
let Some(mut stdout) = child.stdout.take() else { })?;
let _ = child.kill();
let _ = child.wait();
return Err(RawPreviewUnavailable);
};
let reader = std::thread::spawn(move || {
let mut output = Vec::new();
stdout.read_to_end(&mut output).map(|_| output)
});
let started = Instant::now();
let success = loop {
match child.try_wait() {
Ok(Some(status)) => break status.success(),
Ok(None) if started.elapsed() < timeout => {
std::thread::sleep(Duration::from_millis(10));
}
Ok(None) | Err(_) => {
let _ = child.kill();
let _ = child.wait();
break false;
}
}
};
let output = reader
.join()
.ok()
.and_then(Result::ok)
.ok_or(RawPreviewUnavailable)?;
if !success {
return Err(RawPreviewUnavailable);
}
serde_json::from_slice(&output).map_err(|_| RawPreviewUnavailable) serde_json::from_slice(&output).map_err(|_| RawPreviewUnavailable)
} }
/// Whether `name` is a video, whose thumbnail is a frame ffmpeg grabs rather
/// than a picture the image decoder can scale.
fn is_video_name(name: &str) -> bool {
PhotoKind::classify(Some(name), None) == PhotoKind::Video
}
/// Whether ffmpeg can be run at all. Asked once, before a video is downloaded:
/// without ffmpeg every scroll past a video would fetch it whole for nothing.
fn ffmpeg_available() -> bool {
static AVAILABLE: OnceLock<bool> = OnceLock::new();
*AVAILABLE.get_or_init(|| {
let mut command = Command::new("ffmpeg");
command.arg("-version");
let available = run_bounded(command, Duration::from_secs(5)).is_ok();
if !available {
warn_ffmpeg_missing();
}
available
})
}
fn warn_ffmpeg_missing() {
FFMPEG_MISSING_WARNING.call_once(|| {
warn!("ffmpeg is not installed; video thumbnails are unavailable");
});
}
/// A thumbnail from one frame of a downloaded video, which ffmpeg reads from a
/// staging copy. See [`video_file_thumbnail`].
fn video_thumbnail(
bytes: &[u8],
name: &str,
staging_dir: &Path,
ffmpeg: &OsStr,
timeout: Duration,
) -> ScaleAttempt {
let Some(file) = RawTempFile::create_in(staging_dir, bytes, name) else {
return ScaleAttempt::Unavailable;
};
video_file_thumbnail(&file.0, ffmpeg, timeout)
}
/// A thumbnail from one frame of the video at `path`. A second in, past the
/// black lead-in most clips open on, and the first frame for a clip shorter than
/// that. ffmpeg applies the clip's rotation, so a portrait phone video comes out
/// upright.
///
/// Missing or wedged ffmpeg is retryable; a file ffmpeg can take no frame from
/// is the permanent verdict.
fn video_file_thumbnail(path: &Path, ffmpeg: &OsStr, timeout: Duration) -> ScaleAttempt {
for seek in ["1", "0"] {
let mut command = Command::new(ffmpeg);
command.args(["-nostdin", "-loglevel", "error", "-ss", seek, "-i"]);
command.arg(path);
command.args(["-frames:v", "1", "-f", "image2pipe", "-c:v", "mjpeg", "-"]);
match run_bounded(command, timeout) {
Ok(frame) if !frame.is_empty() => {
return match image::load_from_memory(&frame) {
Ok(image) => encode_thumb(&image),
Err(_) => ScaleAttempt::Undecodable,
};
}
// No frame that far in: try from the start.
Ok(_) | Err(ToolError::Failed) => continue,
Err(ToolError::Missing) => {
warn_ffmpeg_missing();
return ScaleAttempt::Unavailable;
}
Err(ToolError::Unavailable) => return ScaleAttempt::Unavailable,
}
}
ScaleAttempt::Undecodable
}
/// The thumbnail to attach to an upload of the local file at `path`, which is
/// named `name` in Drive. Without one, Drive's own apps show a generic icon and
/// the gallery has to download the whole file to make its own.
///
/// Best effort: a file that isn't an image or a video, is too large to read, or
/// can't be decoded uploads without a thumbnail, as it always did. Blocking and
/// CPU-bound, so async callers run it on the blocking pool.
pub(crate) fn upload_thumbnails(path: &Path, name: &str, staging_dir: &Path) -> Vec<Thumbnail> {
if is_video_name(name) && !ffmpeg_available() {
return Vec::new();
}
upload_thumbnails_with(path, name, staging_dir, OsStr::new("ffmpeg"))
}
fn upload_thumbnails_with(
path: &Path,
name: &str,
staging_dir: &Path,
ffmpeg: &OsStr,
) -> Vec<Thumbnail> {
let attempt = if is_video_name(name) {
if !is_video_file(path, name) {
return Vec::new();
}
video_file_thumbnail(path, ffmpeg, FFMPEG_TIMEOUT)
} else if is_thumbnail_image_name(name) {
let fits = std::fs::metadata(path).is_ok_and(|meta| meta.len() <= UPLOAD_IMAGE_MAX_BYTES);
let Some(bytes) = fits.then(|| std::fs::read(path).ok()).flatten() else {
return Vec::new();
};
scale_thumbnail(&bytes, name, staging_dir)
} else {
return Vec::new();
};
let ScaleAttempt::Made(thumb) = attempt else {
debug!(name, "uploading without a thumbnail; none could be made");
return Vec::new();
};
fit_upload_thumb(thumb.bytes)
.and_then(|bytes| Thumbnail::new(ThumbnailType::Thumbnail, bytes).ok())
.into_iter()
.collect()
}
/// `.ts` is TypeScript as often as it is an MPEG transport stream, so a source
/// tree must not cost an ffmpeg run per file. A transport stream opens on its
/// 0x47 sync byte; every other video extension is taken at its word.
fn is_video_file(path: &Path, name: &str) -> bool {
if !name.to_ascii_lowercase().ends_with(".ts") {
return true;
}
let mut first = [0u8; 1];
std::fs::File::open(path)
.and_then(|mut file| file.read_exact(&mut first))
.is_ok_and(|()| first[0] == 0x47)
}
/// Re-encode a thumbnail at falling quality until it fits
/// [`UPLOAD_THUMB_MAX_BYTES`], and at half the size if even the lowest quality
/// doesn't. `None` if nothing fits.
fn fit_upload_thumb(bytes: Vec<u8>) -> Option<Vec<u8>> {
if bytes.len() <= UPLOAD_THUMB_MAX_BYTES {
return Some(bytes);
}
let image = image::load_from_memory(&bytes).ok()?;
let half = image.thumbnail(image.width() / 2, image.height() / 2);
[image.to_rgb8(), half.to_rgb8()].iter().find_map(|image| {
UPLOAD_THUMB_QUALITIES.iter().find_map(|&quality| {
let mut smaller = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut smaller, quality)
.encode_image(image)
.ok()?;
(smaller.len() <= UPLOAD_THUMB_MAX_BYTES).then_some(smaller)
})
})
}
fn exiftool_object( fn exiftool_object(
value: &serde_json::Value, value: &serde_json::Value,
) -> Option<&serde_json::Map<String, serde_json::Value>> { ) -> Option<&serde_json::Map<String, serde_json::Value>> {
@@ -418,6 +612,24 @@ struct PhotoMeta {
} }
impl Core { impl Core {
/// [`upload_thumbnails`] for a file about to upload, staging any RAW copy
/// under this cache. Blocking.
pub(crate) fn upload_thumbnails(&self, path: &Path, name: &str) -> Vec<Thumbnail> {
upload_thumbnails(path, name, self.cache.raw_thumbnail_staging_dir())
}
/// [`Core::upload_thumbnails`] on the blocking pool, for async callers.
pub(crate) async fn upload_thumbnails_async(
&self,
path: PathBuf,
name: String,
) -> Vec<Thumbnail> {
let staging_dir = self.cache.raw_thumbnail_staging_dir().to_path_buf();
tokio::task::spawn_blocking(move || upload_thumbnails(&path, &name, &staging_dir))
.await
.unwrap_or_default()
}
pub(crate) fn photos_timeline( pub(crate) fn photos_timeline(
&self, &self,
offset: usize, offset: usize,
@@ -973,6 +1185,10 @@ impl Core {
if !core.thumb_job_current(job) { if !core.thumb_job_current(job) {
return (uid, ThumbAttempt::Cancelled); return (uid, ThumbAttempt::Cancelled);
} }
let video = is_video_name(&name);
if video && !ffmpeg_available() {
return (uid, ThumbAttempt::Unavailable);
}
// One shared permit pool bounds all thumbnail batches, // One shared permit pool bounds all thumbnail batches,
// rather than multiplying the limit by every control // rather than multiplying the limit by every control
@@ -1019,6 +1235,19 @@ impl Core {
return (uid, ThumbAttempt::Cancelled); return (uid, ThumbAttempt::Cancelled);
} }
// A video is downloaded whole for one frame, so a long
// recording is not worth it — and would not fit in memory.
if video {
match core.photos().get_node(&uid).await {
Ok(Some(node)) if node_size(&node) > VIDEO_THUMB_MAX_BYTES => {
info!(%uid, size = node_size(&node), "video too large for a local thumbnail");
return (uid, ThumbAttempt::Undecodable);
}
Ok(Some(_)) => {}
Ok(None) | Err(_) => return (uid, ThumbAttempt::Unavailable),
}
}
let download_uid = uid.clone(); let download_uid = uid.clone();
let download = async move { client.download_file(&download_uid).await }; let download = async move { client.download_file(&download_uid).await };
tokio::pin!(download); tokio::pin!(download);
@@ -1072,7 +1301,17 @@ impl Core {
// stall the runtime's worker; hand it to the blocking pool. // stall the runtime's worker; hand it to the blocking pool.
let staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf(); let staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf();
let made = tokio::task::spawn_blocking(move || { let made = tokio::task::spawn_blocking(move || {
scale_thumbnail(&bytes, &name, &staging_dir) if video {
video_thumbnail(
&bytes,
&name,
&staging_dir,
OsStr::new("ffmpeg"),
FFMPEG_TIMEOUT,
)
} else {
scale_thumbnail(&bytes, &name, &staging_dir)
}
}) })
.await .await
.unwrap_or(ScaleAttempt::Unavailable); .unwrap_or(ScaleAttempt::Unavailable);
@@ -1592,10 +1831,12 @@ impl Core {
#[cfg(test)] #[cfg(test)]
mod thumb_tests { mod thumb_tests {
use super::{ use super::{
RawTempFile, ScaleAttempt, THUMB_EDGE, exiftool_binary, ratio_of, scale_thumbnail, RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_THUMB_MAX_BYTES, exiftool_binary,
scale_thumbnail_with_exiftool, thumbnail_build_may_start, fit_upload_thumb, is_video_name, ratio_of, scale_thumbnail, scale_thumbnail_with_exiftool,
thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail,
}; };
use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name}; use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name};
use proton_drive_rs::ThumbnailType;
use std::ffi::OsStr; use std::ffi::OsStr;
use std::os::unix::fs::PermissionsExt as _; use std::os::unix::fs::PermissionsExt as _;
use std::time::Duration; use std::time::Duration;
@@ -1758,6 +1999,103 @@ mod thumb_tests {
assert!(matches!(attempt, ScaleAttempt::Unavailable)); assert!(matches!(attempt, ScaleAttempt::Unavailable));
} }
/// An executable shell script standing in for ffmpeg, in its own directory
/// under `tmp` so parallel tests never share one.
fn fake_ffmpeg(tmp: &std::path::Path, body: &str) -> std::path::PathBuf {
std::fs::create_dir_all(tmp).unwrap();
let path = tmp.join("ffmpeg");
std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
fn scratch(test: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("pdfs-{test}-{}", std::process::id()))
}
#[test]
fn a_video_frame_becomes_a_thumbnail() {
let tmp = scratch("video-frame");
let frame = tmp.join("frame.jpg");
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(&frame, jpeg(1080, 1920)).unwrap();
let ffmpeg = fake_ffmpeg(&tmp, &format!("cat '{}'", frame.display()));
let ScaleAttempt::Made(thumb) = video_thumbnail(
b"video bytes",
"clip.mp4",
&tmp,
ffmpeg.as_os_str(),
Duration::from_secs(10),
) else {
panic!("a frame scales");
};
assert!((thumb.ratio - 1080.0 / 1920.0).abs() < 1e-6);
std::fs::remove_dir_all(&tmp).unwrap();
}
#[test]
fn a_video_ffmpeg_cannot_read_is_a_permanent_miss() {
let tmp = scratch("video-broken");
let ffmpeg = fake_ffmpeg(&tmp, "exit 1");
let attempt = video_thumbnail(
b"not a video",
"clip.mp4",
&tmp,
ffmpeg.as_os_str(),
Duration::from_secs(10),
);
assert!(matches!(attempt, ScaleAttempt::Undecodable));
std::fs::remove_dir_all(&tmp).unwrap();
}
#[test]
fn missing_ffmpeg_is_retryable() {
let attempt = video_thumbnail(
b"video bytes",
"clip.mp4",
&std::env::temp_dir(),
OsStr::new("pdfs-ffmpeg-deliberately-absent"),
Duration::from_millis(10),
);
assert!(matches!(attempt, ScaleAttempt::Unavailable));
}
/// Optional real-ffmpeg check, like the RAW fixture harness above.
#[test]
#[ignore = "set PDFS_VIDEO_FIXTURE to a video file; needs ffmpeg"]
fn real_video_fixture_yields_a_frame() {
let path = std::env::var_os("PDFS_VIDEO_FIXTURE").expect("PDFS_VIDEO_FIXTURE");
let path = std::path::PathBuf::from(path);
let name = path.file_name().unwrap().to_string_lossy();
let bytes = std::fs::read(&path).unwrap();
let ScaleAttempt::Made(thumb) = video_thumbnail(
&bytes,
&name,
&std::env::temp_dir(),
OsStr::new("ffmpeg"),
Duration::from_secs(30),
) else {
panic!("a frame from the video");
};
assert!(thumb.ratio > 0.0);
}
#[test]
fn videos_are_told_apart_by_name() {
for name in [
"VID-20260804-WA0023.mp4",
"PXL_1.TS.mp4",
"clip.MOV",
"a.webm",
] {
assert!(is_video_name(name), "{name}");
}
for name in ["photo.jpg", "shot.NEF", "notes.txt", "mp4"] {
assert!(!is_video_name(name), "{name}");
}
}
#[test] #[test]
fn a_running_build_only_accepts_the_same_root() { fn a_running_build_only_accepts_the_same_root() {
let status = ThumbnailBuildStatus { let status = ThumbnailBuildStatus {
@@ -1771,4 +2109,92 @@ mod thumb_tests {
let idle = ThumbnailBuildStatus::default(); let idle = ThumbnailBuildStatus::default();
assert!(thumbnail_build_may_start(&idle, "pictures/second").unwrap()); assert!(thumbnail_build_may_start(&idle, "pictures/second").unwrap());
} }
#[test]
fn an_uploaded_photo_carries_its_thumbnail() {
let tmp = scratch("upload-photo");
std::fs::create_dir_all(&tmp).unwrap();
let photo = tmp.join("staged-blob");
std::fs::write(&photo, jpeg(4000, 3000)).unwrap();
let thumbnails = upload_thumbnails_with(&photo, "IMG_0001.jpg", &tmp, OsStr::new("ffmpeg"));
let _ = std::fs::remove_dir_all(&tmp);
let [thumbnail] = thumbnails.as_slice() else {
panic!("one thumbnail, got {}", thumbnails.len());
};
assert!(matches!(thumbnail.thumbnail_type, ThumbnailType::Thumbnail));
assert!(thumbnail.content.len() <= UPLOAD_THUMB_MAX_BYTES);
assert_eq!(ratio_of(&thumbnail.content), Some(4.0 / 3.0));
}
#[test]
fn an_uploaded_video_carries_a_frame() {
let tmp = scratch("upload-video");
let frame = tmp.join("frame.jpg");
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(&frame, jpeg(1080, 1920)).unwrap();
std::fs::write(tmp.join("clip"), b"video bytes").unwrap();
let ffmpeg = fake_ffmpeg(&tmp, &format!("cat '{}'", frame.display()));
let thumbnails = upload_thumbnails_with(
&tmp.join("clip"),
"VID-WA0001.mp4",
&tmp,
ffmpeg.as_os_str(),
);
let _ = std::fs::remove_dir_all(&tmp);
assert_eq!(thumbnails.len(), 1);
assert!(ratio_of(&thumbnails[0].content).is_some_and(|ratio| ratio < 1.0));
}
#[test]
fn other_files_upload_without_a_thumbnail() {
let tmp = scratch("upload-other");
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("file"), b"export const answer = 42;\n").unwrap();
// An ffmpeg that would hand back a frame, to prove it is never asked.
std::fs::write(tmp.join("frame.jpg"), jpeg(64, 64)).unwrap();
let ffmpeg = fake_ffmpeg(&tmp, &format!("cat '{}'", tmp.join("frame.jpg").display()));
for name in ["notes.txt", "answer.ts", "broken.jpg"] {
assert!(
upload_thumbnails_with(&tmp.join("file"), name, &tmp, ffmpeg.as_os_str())
.is_empty(),
"{name} uploads bare"
);
}
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn an_oversized_thumbnail_is_squeezed_under_the_upload_limit() {
// Noise is the worst case for JPEG: at the gallery's quality a 512 px
// square of it lands far over the limit.
let mut seed = 0x2545_f491_u32;
let noise = image::RgbImage::from_fn(THUMB_EDGE, THUMB_EDGE, |_, _| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
let [r, g, b, _] = seed.to_le_bytes();
image::Rgb([r, g, b])
});
let mut bytes = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, super::THUMB_QUALITY)
.encode_image(&noise)
.unwrap();
assert!(
bytes.len() > UPLOAD_THUMB_MAX_BYTES,
"the fixture is oversized"
);
let fitted = fit_upload_thumb(bytes).expect("noise fits at half size");
assert!(fitted.len() <= UPLOAD_THUMB_MAX_BYTES);
assert_eq!(ratio_of(&fitted), Some(1.0), "shrinking keeps the shape");
let small = jpeg(64, 64);
assert_eq!(
fit_upload_thumb(small.clone()),
Some(small),
"a small one is untouched"
);
}
} }
+8 -2
View File
@@ -1226,6 +1226,9 @@ impl Core {
// Taken from the descriptor being streamed, so it names the content that // Taken from the descriptor being streamed, so it names the content that
// actually goes up rather than whatever is at the path when we finish. // actually goes up rather than whatever is at the path when we finish.
let streamed = LocalSig::from(&meta); let streamed = LocalSig::from(&meta);
let thumbnails = self
.upload_thumbnails_async(path.clone(), name.to_string())
.await;
// Count the bytes as they are read, so this upload shows up in // Count the bytes as they are read, so this upload shows up in
// `GetQueueStatus` next to a manual one. The uid isn't known until the // `GetQueueStatus` next to a manual one. The uid isn't known until the
// draft is sealed, so the transfer registers without one. // draft is sealed, so the transfer registers without one.
@@ -1246,7 +1249,7 @@ impl Core {
crate::media_type_for(name), crate::media_type_for(name),
reader, reader,
meta.len() as i64, meta.len() as i64,
Vec::new(), thumbnails,
Some(mtime), Some(mtime),
false, false,
) )
@@ -1274,6 +1277,9 @@ impl Core {
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?; let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
let mtime = system_mtime(&meta); let mtime = system_mtime(&meta);
let streamed = LocalSig::from(&meta); let streamed = LocalSig::from(&meta);
let thumbnails = self
.upload_thumbnails_async(path.clone(), base_name(rel).to_string())
.await;
let reader = OwnedCountingReader::new( let reader = OwnedCountingReader::new(
file, file,
self.transfers.begin( self.transfers.begin(
@@ -1284,7 +1290,7 @@ impl Core {
), ),
); );
self.client self.client
.upload_new_revision_from(uid, reader, meta.len() as i64, Vec::new(), Some(mtime)) .upload_new_revision_from(uid, reader, meta.len() as i64, thumbnails, Some(mtime))
.await .await
.map_err(|e| format!("upload revision {rel}: {e}"))?; .map_err(|e| format!("upload revision {rel}: {e}"))?;
warn_if_torn(rel, &path, streamed); warn_if_torn(rel, &path, streamed);
+18 -5
View File
@@ -389,11 +389,11 @@ impl<R: Read> Read for CountingReader<'_, R> {
&& cancel.load(Ordering::Relaxed) && cancel.load(Ordering::Relaxed)
{ {
// Not `Ok(0)`: a short read is how this reader says "end of file", // Not `Ok(0)`: a short read is how this reader says "end of file",
// and the SDK would seal a truncated revision from it. // and the SDK would seal a truncated revision from it. Not
return Err(std::io::Error::new( // `Interrupted` either: every reader loop, the SDK's included,
std::io::ErrorKind::Interrupted, // retries that kind, and this flag never clears, so the drain
"upload superseded by a newer write", // thread spun forever with the transfer stuck at 0 bytes (B98).
)); return Err(std::io::Error::other("upload superseded by a newer write"));
} }
let n = self.inner.read(buf)?; let n = self.inner.read(buf)?;
self.guard.add(n as u64); self.guard.add(n as u64);
@@ -433,4 +433,17 @@ mod tests {
assert_eq!(limit.charge(1500, now), Duration::from_millis(500)); assert_eq!(limit.charge(1500, now), Duration::from_millis(500));
assert_eq!(limit.charge(1500, now), Duration::from_secs(2)); assert_eq!(limit.charge(1500, now), Duration::from_secs(2));
} }
#[test]
fn a_cancelled_upload_fails_its_read_instead_of_asking_for_a_retry() {
let reg = TransferRegistry::new();
let guard = reg.begin("f", "", TransferDirection::Upload, 4);
let cancel = Arc::new(AtomicBool::new(true));
let mut reader = CountingReader::new(&b"data"[..], &guard).with_cancel(cancel);
// `read_exact` retries `Interrupted` the way the SDK's block loop does,
// so this hangs rather than fails if the cancel asks for a retry.
let err = reader.read_exact(&mut [0; 4]).unwrap_err();
assert_ne!(err.kind(), std::io::ErrorKind::Interrupted);
assert_eq!(guard.entry.done.load(Ordering::Relaxed), 0);
}
} }
+4 -1
View File
@@ -69,6 +69,9 @@ async fn run_uploads(
format!("destination access denied: errno {}", error.code()), format!("destination access denied: errno {}", error.code()),
)); ));
} }
let thumbnails = core
.upload_thumbnails_async(t.path.clone(), t.name.clone())
.await;
match core match core
.client .client
.upload_file_from( .upload_file_from(
@@ -77,7 +80,7 @@ async fn run_uploads(
media_type_for(&t.name), media_type_for(&t.name),
reader, reader,
t.size as i64, t.size as i64,
Vec::new(), thumbnails,
mtime, mtime,
false, false,
) )
+28
View File
@@ -1294,11 +1294,39 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
.developer_name("Nils Pukropp") .developer_name("Nils Pukropp")
.website("https://github.com/narrrl/proton-drive-linux") .website("https://github.com/narrrl/proton-drive-linux")
.issue_url("https://github.com/narrrl/proton-drive-linux/issues") .issue_url("https://github.com/narrrl/proton-drive-linux/issues")
.copyright("© 2026 Nils Pukropp (contact@narl.io)")
.license_type(gtk4::License::MitX11) .license_type(gtk4::License::MitX11)
.comments(gettext("Files-on-demand Proton Drive for the Linux desktop.\n\nUnofficial client — not affiliated with, endorsed by, or supported by Proton AG.")) .comments(gettext("Files-on-demand Proton Drive for the Linux desktop.\n\nUnofficial client — not affiliated with, endorsed by, or supported by Proton AG."))
.debug_info(debug_info(&ui_about)) .debug_info(debug_info(&ui_about))
.debug_info_filename("proton-drive-linux-debug.txt") .debug_info_filename("proton-drive-linux-debug.txt")
.build(); .build();
dialog.add_link(
&gettext("Source Code"),
"https://github.com/narrrl/proton-drive-linux",
);
dialog.add_link(
&gettext("Proton Terms of Service"),
"https://proton.me/legal/terms",
);
dialog.add_link(
&gettext("Proton Privacy Policy"),
"https://proton.me/legal/privacy",
);
// The text is Pango markup: an ampersand must be written as "&amp;".
dialog.add_legal_section(
&gettext("Disclaimer"),
None,
gtk4::License::Custom,
Some(&gettext(
"This is an unofficial, community-built client. It is not affiliated with, endorsed by, or supported by Proton AG.\n\n“Proton” and “Proton Drive” are trademarks of Proton AG, used here only to describe the service this software connects to. Your use of Proton Drive is governed by Proton's own terms of service and privacy policy.\n\nThis software is provided “as is”, without warranty of any kind. Keep a copy of any data you cannot afford to lose.",
)),
);
dialog.add_legal_section(
"proton-sdk-rs",
Some("© 2026 Nils Pukropp (contact@narl.io)"),
gtk4::License::MitX11,
None,
);
// Translators: replace with your name(s) and email address(es), one // Translators: replace with your name(s) and email address(es), one
// per line, to be credited in the About dialog. // per line, to be credited in the About dialog.
let credits = gettext("translator-credits"); let credits = gettext("translator-credits");
+84 -15
View File
@@ -419,6 +419,21 @@ pub(crate) fn read_exif(path: &str) -> ExifInfo {
.get_field(tag, exif::In::PRIMARY) .get_field(tag, exif::In::PRIMARY)
.map(|f| f.display_value().with_unit(&reader).to_string()) .map(|f| f.display_value().with_unit(&reader).to_string())
}; };
// Text tags, without the quotes `display_value` wraps each string in and
// the NUL padding some cameras leave behind.
let ascii = |tag: exif::Tag| {
let field = reader.get_field(tag, exif::In::PRIMARY)?;
let exif::Value::Ascii(parts) = &field.value else {
return None;
};
let text = parts
.iter()
.map(|part| String::from_utf8_lossy(part).into_owned())
.collect::<Vec<_>>()
.join(" ");
let text = text.trim_matches(|c: char| c == '\0' || c.is_whitespace());
(!text.is_empty()).then(|| text.to_string())
};
if let Some(size) = field(exif::Tag::PixelXDimension) if let Some(size) = field(exif::Tag::PixelXDimension)
.zip(field(exif::Tag::PixelYDimension)) .zip(field(exif::Tag::PixelYDimension))
@@ -428,33 +443,41 @@ pub(crate) fn read_exif(path: &str) -> ExifInfo {
// Translators: label of a photo's pixel dimensions in the details panel. // Translators: label of a photo's pixel dimensions in the details panel.
fields.push((gettext("Dimensions"), size)); fields.push((gettext("Dimensions"), size));
} }
if let Some(taken) = field(exif::Tag::DateTimeOriginal) { if let Some(taken) = ascii(exif::Tag::DateTimeOriginal) {
// Translators: label of when a photo was taken, in the details panel. // Translators: label of when a photo was taken, in the details panel.
fields.push((pgettext("photo property", "Taken"), taken)); fields.push((pgettext("photo property", "Taken"), taken));
} }
let camera = [exif::Tag::Make, exif::Tag::Model] if let Some(camera) = camera_name(ascii(exif::Tag::Make), ascii(exif::Tag::Model)) {
.iter()
.filter_map(|tag| field(*tag))
.collect::<Vec<_>>()
.join(" ");
if !camera.is_empty() {
// Translators: label of the camera model in a photo's details panel. // Translators: label of the camera model in a photo's details panel.
fields.push((gettext("Camera"), camera)); fields.push((gettext("Camera"), camera));
} }
if let Some(lens) = field(exif::Tag::LensModel) { if let Some(lens) = ascii(exif::Tag::LensModel) {
// Translators: label of the lens model in a photo's details panel. // Translators: label of the lens model in a photo's details panel.
fields.push((gettext("Lens"), lens)); fields.push((gettext("Lens"), lens));
} }
let shutter = reader
.get_field(exif::Tag::ExposureTime, exif::In::PRIMARY)
.and_then(|f| match &f.value {
exif::Value::Rational(r) => r.first().map(|r| r.to_f64()),
_ => None,
})
.filter(|seconds| seconds.is_finite() && *seconds > 0.0)
.map(format_shutter);
let iso = reader
.get_field(exif::Tag::PhotographicSensitivity, exif::In::PRIMARY)
.and_then(|f| f.value.get_uint(0))
// Translators: a photo's ISO sensitivity, such as "ISO 400".
.map(|n| gettext_f("ISO {value}", &[("value", &n.to_string())]));
let exposure: Vec<String> = [ let exposure: Vec<String> = [
exif::Tag::FNumber, field(exif::Tag::FNumber),
exif::Tag::ExposureTime, shutter,
exif::Tag::PhotographicSensitivity, iso,
exif::Tag::FocalLength, field(exif::Tag::FocalLength),
] ]
.iter() .into_iter()
.filter_map(|tag| field(*tag)) .flatten()
.collect(); .collect();
if !exposure.is_empty() { if !exposure.is_empty() {
// Translators: label of aperture, shutter speed, ISO and focal length in a photo's details panel. // Translators: label of aperture, shutter speed, ISO and focal length in a photo's details panel.
@@ -479,6 +502,29 @@ pub(crate) fn read_exif(path: &str) -> ExifInfo {
ExifInfo { fields, coords } ExifInfo { fields, coords }
} }
/// "Google Pixel 6" from Make "Google" and Model "Pixel 6", but just "Canon EOS
/// R5" where the model already names the maker.
fn camera_name(make: Option<String>, model: Option<String>) -> Option<String> {
match (make, model) {
(Some(make), Some(model)) if model.to_lowercase().starts_with(&make.to_lowercase()) => {
Some(model)
}
(Some(make), Some(model)) => Some(format!("{make} {model}")),
(make, model) => make.or(model),
}
}
/// A shutter speed the way cameras print it: "1/250 s" below a second, "2.5 s"
/// from one up.
fn format_shutter(seconds: f64) -> String {
if seconds >= 0.95 {
let text = format!("{seconds:.1}");
format!("{} s", text.strip_suffix(".0").unwrap_or(&text))
} else {
format!("1/{} s", (1.0 / seconds).round())
}
}
/// Convert one GPS coordinate from EXIF's degrees/minutes/seconds rationals to /// Convert one GPS coordinate from EXIF's degrees/minutes/seconds rationals to
/// decimal degrees, negating for the S/W hemispheres. /// decimal degrees, negating for the S/W hemispheres.
pub(crate) fn gps_degrees(reader: &exif::Exif, tag: exif::Tag, ref_tag: exif::Tag) -> Option<f64> { pub(crate) fn gps_degrees(reader: &exif::Exif, tag: exif::Tag, ref_tag: exif::Tag) -> Option<f64> {
@@ -1019,8 +1065,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
info_panel.set_width_request(320); info_panel.set_width_request(320);
info_panel.append(&info_scroll); info_panel.append(&info_scroll);
// Set explicitly: the title's hexpand would otherwise propagate up and have
// the revealer take half the window even while it is closed.
let info_revealer = gtk4::Revealer::builder() let info_revealer = gtk4::Revealer::builder()
.transition_type(gtk4::RevealerTransitionType::SlideLeft) .transition_type(gtk4::RevealerTransitionType::SlideLeft)
.hexpand(false)
.child(&info_panel) .child(&info_panel)
.build(); .build();
@@ -1425,7 +1474,27 @@ pub(crate) fn over_photo(picture: &gtk4::Picture, x: f64, y: f64) -> bool {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{named_link, position_label}; use super::{camera_name, format_shutter, named_link, position_label};
#[test]
fn shutter_speeds_read_like_a_camera() {
assert_eq!(format_shutter(1.0 / 18.869_704_689_121_615), "1/19 s");
assert_eq!(format_shutter(0.001), "1/1000 s");
assert_eq!(format_shutter(2.5), "2.5 s");
assert_eq!(format_shutter(1.0), "1 s");
}
#[test]
fn camera_name_does_not_repeat_the_maker() {
let s = |v: &str| Some(v.to_string());
assert_eq!(camera_name(s("Google"), s("Pixel 6")), s("Google Pixel 6"));
assert_eq!(
camera_name(s("Canon"), s("Canon EOS R5")),
s("Canon EOS R5")
);
assert_eq!(camera_name(None, s("iPhone 15")), s("iPhone 15"));
assert_eq!(camera_name(None, None), None);
}
#[test] #[test]
fn the_counter_counts_the_library_while_pages_are_left() { fn the_counter_counts_the_library_while_pages_are_left() {
+35 -8
View File
@@ -157,10 +157,12 @@ pub(crate) struct GalleryState {
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 rows currently realised by the ListView, as row index -> the uid of /// The rows currently realised by the ListView, as row index -> the uid of
/// its first photo (absent for a heading row). A resize or a zoom step /// its first photo (absent for a heading row) and the row's widget. A
/// changes how many tiles fit per row, so the row model has to be rebuilt — /// resize or a zoom step changes how many tiles fit per row, so the row
/// this is what lets the rebuild put the user back where they were. /// model has to be rebuilt — this is what lets the rebuild put the user
pub(crate) bound: RefCell<BTreeMap<u32, Option<String>>>, /// back where they were. The ListView realises rows beyond both edges of
/// the viewport, so the widget is what tells which of them is on screen.
pub(crate) bound: RefCell<BTreeMap<u32, (Option<String>, gtk4::Box)>>,
/// The ListView itself, so a rebuild can scroll back to the row the user was /// The ListView itself, so a rebuild can scroll back to the row the user was
/// looking at. /// looking at.
pub(crate) list: gtk4::ListView, pub(crate) list: gtk4::ListView,
@@ -434,7 +436,8 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
.halign(gtk4::Align::End) .halign(gtk4::Align::End)
.margin_top(12) .margin_top(12)
.margin_bottom(12) .margin_bottom(12)
.margin_end(4) // Clear of the overlay scrollbar, which widens under the pointer.
.margin_end(18)
.tooltip_text(gettext("Jump to a month")) .tooltip_text(gettext("Jump to a month"))
.visible(false) .visible(false)
.build(); .build();
@@ -900,7 +903,7 @@ pub(crate) fn wire_gallery(
.gallery .gallery
.bound .bound
.borrow_mut() .borrow_mut()
.insert(item.position(), row.anchor()); .insert(item.position(), (row.anchor(), row_box.clone()));
}); });
// ListView recycles row widgets, so a scrolled-away row must give up its // ListView recycles row widgets, so a scrolled-away row must give up its
@@ -2004,7 +2007,7 @@ pub(crate) fn schedule_relayout(ui: &Rc<Ui>) {
/// rebuilt. What that would cost the user is their scroll position, so the /// rebuilt. What that would cost the user is their scroll position, so the
/// topmost realised row's first photo is remembered and scrolled back to. /// topmost realised row's first photo is remembered and scrolled back to.
pub(crate) fn relayout_gallery(ui: &Rc<Ui>) { pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
let anchor: Option<String> = ui.gallery.bound.borrow().values().flatten().next().cloned(); let anchor = top_anchor(ui);
repaint_gallery(ui); repaint_gallery(ui);
let Some(anchor) = anchor else { return }; let Some(anchor) = anchor else { return };
let Some(row) = row_of_photo(&ui.gallery.groups, &anchor) else { let Some(row) = row_of_photo(&ui.gallery.groups, &anchor) else {
@@ -2015,6 +2018,30 @@ pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
.scroll_to(row, gtk4::ListScrollFlags::empty(), None); .scroll_to(row, gtk4::ListScrollFlags::empty(), None);
} }
/// The first photo of the topmost photo row that reaches into the viewport.
///
/// Not simply the first bound row: the ListView keeps rows bound well outside
/// the viewport (unmapped, with their last allocation), and scrolling one of
/// those "back" to the top is a jump. Falls back to the first bound photo row
/// while the list isn't on screen.
fn top_anchor(ui: &Rc<Ui>) -> Option<String> {
let list = &ui.gallery.list;
let bound = ui.gallery.bound.borrow();
let mut photo_rows = bound
.values()
.filter_map(|(uid, row)| uid.as_ref().map(|uid| (uid, row)));
let first = photo_rows.clone().next().map(|(uid, _)| uid.clone());
photo_rows
.find(|(_, row)| {
row.is_mapped()
&& row
.compute_bounds(list)
.is_some_and(|rect| rect.y() + rect.height() > 0.0)
})
.map(|(uid, _)| uid.clone())
.or(first)
}
/// Which row of the rendered model holds `uid`, if any. /// Which row of the rendered model holds `uid`, if any.
fn row_of_photo(store: &gio::ListStore, uid: &str) -> Option<u32> { fn row_of_photo(store: &gio::ListStore, uid: &str) -> Option<u32> {
(0..store.n_items()).find(|i| { (0..store.n_items()).find(|i| {
@@ -2329,7 +2356,7 @@ fn sync_scrubber_position(ui: &Rc<Ui>) {
if ui.gallery.scrubbing.get() || !ui.gallery.scrubber.is_visible() { if ui.gallery.scrubbing.get() || !ui.gallery.scrubber.is_visible() {
return; return;
} }
let Some(uid) = ui.gallery.bound.borrow().values().flatten().next().cloned() else { let Some(uid) = top_anchor(ui) else {
return; return;
}; };
let Some(capture_time) = find_photo_index(&ui.gallery.model, &uid) let Some(capture_time) = find_photo_index(&ui.gallery.model, &uid)
+25
View File
@@ -12,6 +12,31 @@ Conventions:
--- ---
## B98 — A superseded upload spins its drain thread forever and shows as "Uploading" at 0 bytes
**Status:** Fixed (unverified) — the daemon carrying the change has not been driven against a
superseded upload yet.
**Found:** 2026-09-23. The Sync page showed `Unconfirmed 654050.crdownload — 0 bytes of 23.8 MiB`
for hours after Brave had finished and renamed the download. `pdfs transfers` listed the same
entry, `pdfs diagnostics` showed every worker idle, and `/proc/<pid>/task` showed `pdfs-drain-1`
in state `R` at 100% CPU, holding a deleted staging blob open.
**Where:** `crates/pdfs-fuse/src/transfers.rs` (`CountingReader::read`).
**Cause.** A write that lands while a revision is on the wire cancels that upload through the
flag `CountingReader` checks before every read. The reader reported the cancel as
`ErrorKind::Interrupted`. Every read loop treats that kind as "try again", including the SDK's
`read_full_block`, and the flag never clears, so the loop never ended. The `TransferGuard` was
never dropped, so the transfer stayed in the registry, and the drain thread was lost until the
daemon restarted. A browser download is the natural trigger: the `.crdownload` file keeps growing,
so each new write supersedes the upload of the previous one.
**Fix.** The cancel is reported as `ErrorKind::Other`, which the SDK turns into an error.
`drain_revision` already recognises a cancelled upload and returns without counting a failure.
Regression test: `a_cancelled_upload_fails_its_read_instead_of_asking_for_a_retry`.
---
## B97 — Photo refreshes stack up on the control socket, and shutdown turns the resolve pass into a burst of "undecryptable photo" warnings ## B97 — Photo refreshes stack up on the control socket, and shutdown turns the resolve pass into a burst of "undecryptable photo" warnings
**Status:** Fixed (unverified) — the daemon carrying the change has not been driven against a real **Status:** Fixed (unverified) — the daemon carrying the change has not been driven against a real
+38
View File
@@ -11,6 +11,43 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased] ## [Unreleased]
## [2.2.1] — 2026-09-24
Schema: **32** (unchanged).
### Added
- **Legal information in About.** The About dialog now links to the source code and to Proton's
terms of service and privacy policy, and its Legal page carries the full disclaimer: this client
is unofficial, not affiliated with or endorsed by Proton AG, and comes without warranty.
## [2.2.0] — 2026-09-23
Schema: **32**. Videos that earlier got no thumbnail are tried again.
### Added
- **Video thumbnails.** Videos without a thumbnail from Drive now show a frame from the clip in
Photos. This needs `ffmpeg`, a new optional dependency.
- **Uploads carry a thumbnail.** Photos and videos uploaded from this client now include one, so
they show a preview in Proton's own apps as well, without downloading the whole file.
### Fixed
- **The Photos gallery no longer jumps** while thumbnails load, when zooming, or when switching
tabs and back.
- **The month scrubber no longer collides with the scrollbar.**
- **The photo details panel is no longer empty.**
- **Photo details read like a camera's:** "Google Pixel 6" instead of quoted EXIF values, shutter
speeds such as 1/19 s, and a labelled ISO.
## [2.1.1] — 2026-09-23
Schema: **31** (unchanged).
### Fixed
- **Uploads no longer get stuck at 0 bytes.** When a file changed again while its previous
version was uploading, such as a browser download that is still growing, the old upload could
hang forever. It kept one background thread busy at full CPU and showed as "Uploading" in the
Transfers list until the daemon restarted.
## [2.1.0] — 2026-09-23 ## [2.1.0] — 2026-09-23
The desktop app, the tray and the search prompt speak German, British English, Spanish, French, The desktop app, the tray and the search prompt speak German, British English, Spanish, French,
@@ -1001,6 +1038,7 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated - The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
by a new POSIX compliance suite for the filesystem. by a new POSIX compliance suite for the filesystem.
[2.2.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.2.1
[2.1.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.1.0 [2.1.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.1.0
[2.0.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.0.0 [2.0.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.0.0
[1.11.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.11.1 [1.11.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.11.1
+2 -1
View File
@@ -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=2.1.0 pkgver=2.2.1
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray" pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
arch=('x86_64') arch=('x86_64')
@@ -20,6 +20,7 @@ makedepends=('cargo' 'git' 'gettext')
provides=('pdfs') provides=('pdfs')
# Files-on-demand mount + keyring need these at runtime. # Files-on-demand mount + keyring need these at runtime.
optdepends=('gnome-keyring: credential storage via Secret Service' optdepends=('gnome-keyring: credential storage via Secret Service'
'ffmpeg: video thumbnails'
'xdg-utils: "Open folder" action from the tray' 'xdg-utils: "Open folder" action from the tray'
'fzf: live search-as-you-type launcher (pdfs-prompt --fzf)' 'fzf: live search-as-you-type launcher (pdfs-prompt --fzf)'
'foot: terminal for pdfs-prompt --fzf started from a keybinding') 'foot: terminal for pdfs-prompt --fzf started from a keybinding')
+18 -1
View File
@@ -21,6 +21,22 @@ built packages never reach the AUR.
## Releasing a new version ## Releasing a new version
`.github/workflows/aur.yml` does this automatically:
- **On a release tag**, `release.yml` calls it after the GitHub release is
published. It runs `./update.sh <version>`, pushes all three packages with
`./push.sh`, and commits the updated recipes back to `main` as
`chore(aur): update packages to <version>`.
- **On a push to `main` that changes `proton-drive-for-linux-git/`**, it
regenerates that package's `.SRCINFO` and pushes only the -git package.
- **By hand**, run the workflow from the Actions tab. Give a version to publish
a release, or leave it empty to publish only the -git package.
It needs the `AUR_SSH_PRIVATE_KEY` repository secret: a private key whose public
half is registered with the AUR account.
To do the same by hand:
1. Tag and publish the upstream release, so the source tarball and the 1. Tag and publish the upstream release, so the source tarball and the
`proton-drive-linux-<version>-x86_64.tar.gz` release asset exist. `proton-drive-linux-<version>-x86_64.tar.gz` release asset exist.
2. Run `./update.sh <version>` — it rewrites `pkgver`, resets `pkgrel=1`, 2. Run `./update.sh <version>` — it rewrites `pkgver`, resets `pkgrel=1`,
@@ -32,7 +48,8 @@ built packages never reach the AUR.
for a single package). for a single package).
`proton-drive-for-linux-git` carries a `pkgver()` function, so its `pkgver` is `proton-drive-for-linux-git` carries a `pkgver()` function, so its `pkgver` is
computed at build time; do not push a commit that only bumps it. computed at build time; do not push a commit that only bumps it. This is why new
commits on `main` do not trigger an AUR push.
## Requirements for pushing ## Requirements for pushing
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux-bin pkgbase = proton-drive-for-linux-bin
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries) pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
pkgver = 2.0.0 pkgver = 2.2.0
pkgrel = 1 pkgrel = 1
url = https://github.com/narrrl/proton-drive-linux url = https://github.com/narrrl/proton-drive-linux
arch = x86_64 arch = x86_64
@@ -14,28 +14,29 @@ pkgbase = proton-drive-for-linux-bin
depends = glibc depends = glibc
depends = hicolor-icon-theme depends = hicolor-icon-theme
optdepends = perl-image-exiftool: thumbnails for camera RAW files optdepends = perl-image-exiftool: thumbnails for camera RAW files
optdepends = ffmpeg: video thumbnails
optdepends = gnome-keyring: credential storage over the Secret Service API optdepends = gnome-keyring: credential storage over the Secret Service API
optdepends = kwallet: credential storage over the Secret Service API optdepends = kwallet: credential storage over the Secret Service API
optdepends = xdg-utils: "Open folder" action in the tray menu optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf) optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-for-linux=2.0.0 provides = proton-drive-for-linux=2.2.0
provides = proton-drive-linux=2.0.0 provides = proton-drive-linux=2.2.0
provides = pdfs provides = pdfs
conflicts = proton-drive-linux conflicts = proton-drive-linux
conflicts = proton-drive-for-linux conflicts = proton-drive-for-linux
conflicts = proton-drive-for-linux-git conflicts = proton-drive-for-linux-git
options = !strip options = !strip
options = !debug options = !debug
source = proton-drive-linux-2.0.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v2.0.0/proton-drive-linux-2.0.0-x86_64.tar.gz source = proton-drive-linux-2.2.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v2.2.0/proton-drive-linux-2.2.0-x86_64.tar.gz
source = io.narl.proton-drive-linux.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.0.0/packaging/io.narl.proton-drive-linux.desktop source = io.narl.proton-drive-linux.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.0/packaging/io.narl.proton-drive-linux.desktop
source = io.narl.proton-drive-linux-tray.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.0.0/packaging/io.narl.proton-drive-linux-tray.desktop source = io.narl.proton-drive-linux-tray.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.0/packaging/io.narl.proton-drive-linux-tray.desktop
source = io.narl.proton-drive-linux.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.0.0/packaging/io.narl.proton-drive-linux.svg source = io.narl.proton-drive-linux.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.0/packaging/io.narl.proton-drive-linux.svg
source = proton-drive.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.0.0/packaging/proton-drive.service source = proton-drive.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.0/packaging/proton-drive.service
source = LICENSE-2.0.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.0.0/LICENSE source = LICENSE-2.2.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.0/LICENSE
sha256sums = 78d3bda3810964383cc0917cf8af276f1dc3776dcd4fe725754f5dafb371dfe6 sha256sums = 4d746d83abd5174acb324236b6146c083a43b8c25e8d30e31c2a6d5b594ba479
sha256sums = 034083fd2f77b0b69ae638fdaba2bd772209ca325a78ca17d050f221cab9650b sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
sha256sums = 5c369d689926cbbd64803b55a9a6010ad59aded6c7bcfb3e8bd005c95ac10cc8 sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828 sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
sha256sums = 25fa3bd9ba52df534be9d8bc66f77d7502fe1423177237d0d14b673dedeb1b60 sha256sums = 25fa3bd9ba52df534be9d8bc66f77d7502fe1423177237d0d14b673dedeb1b60
sha256sums = 02c419e7f9af08dda720e4f852f3e57cf5a69e192605440f750e9d244fa2817c sha256sums = 02c419e7f9af08dda720e4f852f3e57cf5a69e192605440f750e9d244fa2817c
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io> # Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux-bin pkgname=proton-drive-for-linux-bin
pkgver=2.0.0 pkgver=2.2.0
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)" pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
arch=('x86_64') arch=('x86_64')
@@ -10,6 +10,7 @@ license=('MIT')
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc' depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
'hicolor-icon-theme') 'hicolor-icon-theme')
optdepends=('perl-image-exiftool: thumbnails for camera RAW files' optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
'ffmpeg: video thumbnails'
'gnome-keyring: credential storage over the Secret Service API' 'gnome-keyring: credential storage over the Secret Service API'
'kwallet: credential storage over the Secret Service API' 'kwallet: credential storage over the Secret Service API'
'xdg-utils: "Open folder" action in the tray menu' 'xdg-utils: "Open folder" action in the tray menu'
@@ -29,9 +30,9 @@ source=("proton-drive-linux-$pkgver-x86_64.tar.gz::$url/releases/download/$_tag/
"io.narl.proton-drive-linux.svg::$_raw/packaging/io.narl.proton-drive-linux.svg" "io.narl.proton-drive-linux.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
"proton-drive.service::$_raw/packaging/proton-drive.service" "proton-drive.service::$_raw/packaging/proton-drive.service"
"LICENSE-$pkgver::$_raw/LICENSE") "LICENSE-$pkgver::$_raw/LICENSE")
sha256sums=('78d3bda3810964383cc0917cf8af276f1dc3776dcd4fe725754f5dafb371dfe6' sha256sums=('4d746d83abd5174acb324236b6146c083a43b8c25e8d30e31c2a6d5b594ba479'
'034083fd2f77b0b69ae638fdaba2bd772209ca325a78ca17d050f221cab9650b' 'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
'5c369d689926cbbd64803b55a9a6010ad59aded6c7bcfb3e8bd005c95ac10cc8' '1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828' 'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
'25fa3bd9ba52df534be9d8bc66f77d7502fe1423177237d0d14b673dedeb1b60' '25fa3bd9ba52df534be9d8bc66f77d7502fe1423177237d0d14b673dedeb1b60'
'02c419e7f9af08dda720e4f852f3e57cf5a69e192605440f750e9d244fa2817c') '02c419e7f9af08dda720e4f852f3e57cf5a69e192605440f750e9d244fa2817c')
@@ -1,12 +1,13 @@
pkgbase = proton-drive-for-linux-git pkgbase = proton-drive-for-linux-git
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git) pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
pkgver = 2.0.0.r1.g56161f6 pkgver = 2.2.0.r1.g82e9654
pkgrel = 1 pkgrel = 1
url = https://github.com/narrrl/proton-drive-linux url = https://github.com/narrrl/proton-drive-linux
arch = x86_64 arch = x86_64
license = MIT license = MIT
makedepends = cargo makedepends = cargo
makedepends = git makedepends = git
makedepends = gettext
depends = fuse3 depends = fuse3
depends = gtk4 depends = gtk4
depends = libadwaita depends = libadwaita
@@ -16,13 +17,14 @@ pkgbase = proton-drive-for-linux-git
depends = glibc depends = glibc
depends = hicolor-icon-theme depends = hicolor-icon-theme
optdepends = perl-image-exiftool: thumbnails for camera RAW files optdepends = perl-image-exiftool: thumbnails for camera RAW files
optdepends = ffmpeg: video thumbnails
optdepends = gnome-keyring: credential storage over the Secret Service API optdepends = gnome-keyring: credential storage over the Secret Service API
optdepends = kwallet: credential storage over the Secret Service API optdepends = kwallet: credential storage over the Secret Service API
optdepends = xdg-utils: "Open folder" action in the tray menu optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf) optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-for-linux=2.0.0.r1.g56161f6 provides = proton-drive-for-linux=2.2.0.r1.g82e9654
provides = proton-drive-linux=2.0.0.r1.g56161f6 provides = proton-drive-linux=2.2.0.r1.g82e9654
provides = pdfs provides = pdfs
conflicts = proton-drive-linux conflicts = proton-drive-linux
conflicts = proton-drive-for-linux conflicts = proton-drive-for-linux
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io> # Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux-git pkgname=proton-drive-for-linux-git
pkgver=2.0.0.r1.g56161f6 pkgver=2.2.0.r1.g82e9654
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)" pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
arch=('x86_64') arch=('x86_64')
@@ -11,6 +11,7 @@ depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
'hicolor-icon-theme') 'hicolor-icon-theme')
makedepends=('cargo' 'git' 'gettext') makedepends=('cargo' 'git' 'gettext')
optdepends=('perl-image-exiftool: thumbnails for camera RAW files' optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
'ffmpeg: video thumbnails'
'gnome-keyring: credential storage over the Secret Service API' 'gnome-keyring: credential storage over the Secret Service API'
'kwallet: credential storage over the Secret Service API' 'kwallet: credential storage over the Secret Service API'
'xdg-utils: "Open folder" action in the tray menu' 'xdg-utils: "Open folder" action in the tray menu'
@@ -1,11 +1,12 @@
pkgbase = proton-drive-for-linux pkgbase = proton-drive-for-linux
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
pkgver = 2.0.0 pkgver = 2.2.0
pkgrel = 1 pkgrel = 1
url = https://github.com/narrrl/proton-drive-linux url = https://github.com/narrrl/proton-drive-linux
arch = x86_64 arch = x86_64
license = MIT license = MIT
makedepends = cargo makedepends = cargo
makedepends = gettext
depends = fuse3 depends = fuse3
depends = gtk4 depends = gtk4
depends = libadwaita depends = libadwaita
@@ -15,18 +16,19 @@ pkgbase = proton-drive-for-linux
depends = glibc depends = glibc
depends = hicolor-icon-theme depends = hicolor-icon-theme
optdepends = perl-image-exiftool: thumbnails for camera RAW files optdepends = perl-image-exiftool: thumbnails for camera RAW files
optdepends = ffmpeg: video thumbnails
optdepends = gnome-keyring: credential storage over the Secret Service API optdepends = gnome-keyring: credential storage over the Secret Service API
optdepends = kwallet: credential storage over the Secret Service API optdepends = kwallet: credential storage over the Secret Service API
optdepends = xdg-utils: "Open folder" action in the tray menu optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf) optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-linux=2.0.0 provides = proton-drive-linux=2.2.0
provides = pdfs provides = pdfs
conflicts = proton-drive-linux conflicts = proton-drive-linux
conflicts = proton-drive-for-linux-bin conflicts = proton-drive-for-linux-bin
conflicts = proton-drive-for-linux-git conflicts = proton-drive-for-linux-git
options = !lto options = !lto
source = proton-drive-for-linux-2.0.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v2.0.0.tar.gz source = proton-drive-for-linux-2.2.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v2.2.0.tar.gz
sha256sums = 4476aafe405cb91d81cec8cffcef345696f3169ac3fbde0121233913c8777eea sha256sums = 73f68211d008894c3254b5adca0acca7f9e1af90e89209c22ae0213aa75add4b
pkgname = proton-drive-for-linux pkgname = proton-drive-for-linux
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io> # Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux pkgname=proton-drive-for-linux
pkgver=2.0.0 pkgver=2.2.0
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray" pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
arch=('x86_64') arch=('x86_64')
@@ -11,6 +11,7 @@ depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
'hicolor-icon-theme') 'hicolor-icon-theme')
makedepends=('cargo' 'gettext') makedepends=('cargo' 'gettext')
optdepends=('perl-image-exiftool: thumbnails for camera RAW files' optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
'ffmpeg: video thumbnails'
'gnome-keyring: credential storage over the Secret Service API' 'gnome-keyring: credential storage over the Secret Service API'
'kwallet: credential storage over the Secret Service API' 'kwallet: credential storage over the Secret Service API'
'xdg-utils: "Open folder" action in the tray menu' 'xdg-utils: "Open folder" action in the tray menu'
@@ -21,7 +22,7 @@ conflicts=('proton-drive-linux' 'proton-drive-for-linux-bin' 'proton-drive-for-l
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off. # LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
options=('!lto') options=('!lto')
source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz") source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('4476aafe405cb91d81cec8cffcef345696f3169ac3fbde0121233913c8777eea') sha256sums=('73f68211d008894c3254b5adca0acca7f9e1af90e89209c22ae0213aa75add4b')
_srcname="proton-drive-linux-$pkgver" _srcname="proton-drive-linux-$pkgver"
+5
View File
@@ -21,6 +21,11 @@ for pkg in proton-drive-for-linux proton-drive-for-linux-bin; do
(cd "$pkg" && updpkgsums) (cd "$pkg" && updpkgsums)
done done
# --printsrcinfo does not run pkgver(), so let makepkg clone the source and
# write the current git describe version back into the -git PKGBUILD first.
# --noprepare skips the cargo fetch in prepare().
(cd proton-drive-for-linux-git && makepkg -od --noprepare --noconfirm && rm -rf src)
for pkg in proton-drive-for-linux proton-drive-for-linux-git proton-drive-for-linux-bin; do for pkg in proton-drive-for-linux proton-drive-for-linux-git proton-drive-for-linux-bin; do
(cd "$pkg" && makepkg --printsrcinfo > .SRCINFO) (cd "$pkg" && makepkg --printsrcinfo > .SRCINFO)
echo "updated $pkg" echo "updated $pkg"
+224 -177
View File
File diff suppressed because it is too large. Load diff
+221 -177
View File
File diff suppressed because it is too large. Load diff
+222 -177
View File
File diff suppressed because it is too large. Load diff
+223 -177
View File
File diff suppressed because it is too large. Load diff
+222 -177
View File
File diff suppressed because it is too large. Load diff
+222 -177
View File
File diff suppressed because it is too large. Load diff
+213 -178
View File
File diff suppressed because it is too large. Load diff
+223 -177
View File
File diff suppressed because it is too large. Load diff
+222 -177
View File
File diff suppressed because it is too large. Load diff