Compare commits

..
10 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
29 changed files with 2773 additions and 1710 deletions

No files matched your search

Generated
+4 -4
View File
@@ -3449,7 +3449,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]]
name = "pdfs-cli"
version = "2.1.1"
version = "2.2.1"
dependencies = [
"anyhow",
"clap",
@@ -3464,7 +3464,7 @@ dependencies = [
[[package]]
name = "pdfs-core"
version = "2.1.1"
version = "2.2.1"
dependencies = [
"async-trait",
"dbus-secret-service-keyring-store",
@@ -3488,7 +3488,7 @@ dependencies = [
[[package]]
name = "pdfs-fuse"
version = "2.1.1"
version = "2.2.1"
dependencies = [
"base64 0.23.1",
"fuser",
@@ -3508,7 +3508,7 @@ dependencies = [
[[package]]
name = "pdfs-gui"
version = "2.1.1"
version = "2.2.1"
dependencies = [
"anyhow",
"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"]
[workspace.package]
version = "2.1.1"
version = "2.2.1"
edition = "2024"
license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"]
+22 -1
View File
@@ -9,7 +9,7 @@ use super::Db;
use crate::Result;
/// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 31;
pub(super) const SCHEMA_VERSION: i64 = 32;
impl Db {
pub(super) fn migrate(&self) -> Result<()> {
@@ -270,6 +270,20 @@ impl Db {
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(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
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);
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
/// shows every photo — a row with no `group_key` is its own group, which is how
/// 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]
fn migration_v30_leaves_a_v29_timeline_intact() {
let path = std::env::temp_dir().join(format!(
+6 -3
View File
@@ -945,6 +945,7 @@ impl Core {
if !meta.complete {
return Err("queued create holds an incomplete blob".into());
}
let thumbnails = self.upload_thumbnails(Path::new(blob), name);
let guard = self
.transfers
.begin(name, op.uid.clone(), TransferDirection::Upload, meta.len);
@@ -955,7 +956,7 @@ impl Core {
media_type_for(name),
reader,
meta.len as i64,
Vec::new(),
thumbnails,
None,
false,
))?;
@@ -1145,6 +1146,7 @@ impl Core {
}
let alt = conflict_name(&name, now_secs());
let thumbnails = self.upload_thumbnails(blob, &alt);
let guard =
self.transfers
.begin(&alt, meta.uid.clone(), TransferDirection::Upload, meta.len);
@@ -1155,7 +1157,7 @@ impl Core {
media_type_for(&alt),
reader,
meta.len as i64,
Vec::new(),
thumbnails,
None,
false,
))?;
@@ -1438,6 +1440,7 @@ impl Core {
}
let name = self.node_name(&uid);
let thumbnails = self.upload_thumbnails(&blob, &name);
let guard =
self.transfers
.begin(&name, meta.uid.clone(), TransferDirection::Upload, meta.len);
@@ -1451,7 +1454,7 @@ impl Core {
&uid,
reader,
meta.len as i64,
Vec::new(),
thumbnails,
None,
));
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::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::Once;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Once, OnceLock};
use std::time::{Duration, Instant};
use base64::Engine as _;
@@ -25,8 +25,8 @@ use pdfs_core::db::{self, StoredPhoto};
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::NodeUid;
use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, ThumbnailType};
use tracing::{info, warn};
use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, Thumbnail, ThumbnailType};
use tracing::{debug, info, warn};
use super::{
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);
static RAW_TEMP_NONCE: AtomicU64 = AtomicU64::new(0);
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
/// 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.
enum ThumbAttempt {
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,
/// The photo couldn't be downloaded. Transient: try again next time.
Unavailable,
@@ -196,6 +211,11 @@ fn scale_thumbnail_with_exiftool(
}
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());
if width == 0 || height == 0 {
return ScaleAttempt::Undecodable;
@@ -268,9 +288,69 @@ impl Drop for RawTempFile {
}
}
/// Run one bounded exiftool JSON query while draining stdout concurrently. The
/// latter matters for full-size `JpgFromRaw` fallbacks, whose base64 can exceed a
/// pipe buffer long before the child exits.
/// How a bounded helper run failed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
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(
exiftool: &OsStr,
path: &Path,
@@ -280,58 +360,172 @@ fn exiftool_query(
let mut command = Command::new(exiftool);
command.args(["-j", "-b", "-n"]);
command.args(tags);
command
.arg(path)
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = match command.spawn() {
Ok(child) => child,
Err(error) => {
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);
command.arg(path);
let output = run_bounded(command, timeout).map_err(|error| {
if error == ToolError::Missing {
EXIFTOOL_MISSING_WARNING.call_once(|| {
warn!(
"exiftool is not installed; camera RAW thumbnails are unavailable \
(Debian/Ubuntu: libimage-exiftool-perl)"
);
});
}
};
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);
}
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(
value: &serde_json::Value,
) -> Option<&serde_json::Map<String, serde_json::Value>> {
@@ -418,6 +612,24 @@ struct PhotoMeta {
}
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(
&self,
offset: usize,
@@ -973,6 +1185,10 @@ impl Core {
if !core.thumb_job_current(job) {
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,
// rather than multiplying the limit by every control
@@ -1019,6 +1235,19 @@ impl Core {
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 = async move { client.download_file(&download_uid).await };
tokio::pin!(download);
@@ -1072,7 +1301,17 @@ impl Core {
// stall the runtime's worker; hand it to the blocking pool.
let staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf();
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
.unwrap_or(ScaleAttempt::Unavailable);
@@ -1592,10 +1831,12 @@ impl Core {
#[cfg(test)]
mod thumb_tests {
use super::{
RawTempFile, ScaleAttempt, THUMB_EDGE, exiftool_binary, ratio_of, scale_thumbnail,
scale_thumbnail_with_exiftool, thumbnail_build_may_start,
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_THUMB_MAX_BYTES, exiftool_binary,
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 proton_drive_rs::ThumbnailType;
use std::ffi::OsStr;
use std::os::unix::fs::PermissionsExt as _;
use std::time::Duration;
@@ -1758,6 +1999,103 @@ mod thumb_tests {
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]
fn a_running_build_only_accepts_the_same_root() {
let status = ThumbnailBuildStatus {
@@ -1771,4 +2109,92 @@ mod thumb_tests {
let idle = ThumbnailBuildStatus::default();
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
// actually goes up rather than whatever is at the path when we finish.
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
// `GetQueueStatus` next to a manual one. The uid isn't known until the
// draft is sealed, so the transfer registers without one.
@@ -1246,7 +1249,7 @@ impl Core {
crate::media_type_for(name),
reader,
meta.len() as i64,
Vec::new(),
thumbnails,
Some(mtime),
false,
)
@@ -1274,6 +1277,9 @@ impl Core {
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
let mtime = system_mtime(&meta);
let streamed = LocalSig::from(&meta);
let thumbnails = self
.upload_thumbnails_async(path.clone(), base_name(rel).to_string())
.await;
let reader = OwnedCountingReader::new(
file,
self.transfers.begin(
@@ -1284,7 +1290,7 @@ impl Core {
),
);
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
.map_err(|e| format!("upload revision {rel}: {e}"))?;
warn_if_torn(rel, &path, streamed);
+4 -1
View File
@@ -69,6 +69,9 @@ async fn run_uploads(
format!("destination access denied: errno {}", error.code()),
));
}
let thumbnails = core
.upload_thumbnails_async(t.path.clone(), t.name.clone())
.await;
match core
.client
.upload_file_from(
@@ -77,7 +80,7 @@ async fn run_uploads(
media_type_for(&t.name),
reader,
t.size as i64,
Vec::new(),
thumbnails,
mtime,
false,
)
+28
View File
@@ -1294,11 +1294,39 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
.developer_name("Nils Pukropp")
.website("https://github.com/narrrl/proton-drive-linux")
.issue_url("https://github.com/narrrl/proton-drive-linux/issues")
.copyright("© 2026 Nils Pukropp (contact@narl.io)")
.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."))
.debug_info(debug_info(&ui_about))
.debug_info_filename("proton-drive-linux-debug.txt")
.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
// per line, to be credited in the About dialog.
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)
.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)
.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.
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.
fields.push((pgettext("photo property", "Taken"), taken));
}
let camera = [exif::Tag::Make, exif::Tag::Model]
.iter()
.filter_map(|tag| field(*tag))
.collect::<Vec<_>>()
.join(" ");
if !camera.is_empty() {
if let Some(camera) = camera_name(ascii(exif::Tag::Make), ascii(exif::Tag::Model)) {
// Translators: label of the camera model in a photo's details panel.
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.
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> = [
exif::Tag::FNumber,
exif::Tag::ExposureTime,
exif::Tag::PhotographicSensitivity,
exif::Tag::FocalLength,
field(exif::Tag::FNumber),
shutter,
iso,
field(exif::Tag::FocalLength),
]
.iter()
.filter_map(|tag| field(*tag))
.into_iter()
.flatten()
.collect();
if !exposure.is_empty() {
// 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 }
}
/// "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
/// 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> {
@@ -1019,8 +1065,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
info_panel.set_width_request(320);
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()
.transition_type(gtk4::RevealerTransitionType::SlideLeft)
.hexpand(false)
.child(&info_panel)
.build();
@@ -1425,7 +1474,27 @@ pub(crate) fn over_photo(picture: &gtk4::Picture, x: f64, y: f64) -> bool {
#[cfg(test)]
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]
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) relayout_source: RefCell<Option<glib::SourceId>>,
/// 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
/// changes how many tiles fit per row, so the row model has to be rebuilt —
/// this is what lets the rebuild put the user back where they were.
pub(crate) bound: RefCell<BTreeMap<u32, Option<String>>>,
/// its first photo (absent for a heading row) and the row's widget. A
/// resize or a zoom step changes how many tiles fit per row, so the row
/// model has to be rebuilt — this is what lets the rebuild put the user
/// 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
/// looking at.
pub(crate) list: gtk4::ListView,
@@ -434,7 +436,8 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
.halign(gtk4::Align::End)
.margin_top(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"))
.visible(false)
.build();
@@ -900,7 +903,7 @@ pub(crate) fn wire_gallery(
.gallery
.bound
.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
@@ -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
/// topmost realised row's first photo is remembered and scrolled back to.
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);
let Some(anchor) = anchor else { return };
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);
}
/// 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.
fn row_of_photo(store: &gio::ListStore, uid: &str) -> Option<u32> {
(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() {
return;
}
let Some(uid) = ui.gallery.bound.borrow().values().flatten().next().cloned() else {
let Some(uid) = top_anchor(ui) else {
return;
};
let Some(capture_time) = find_photo_index(&ui.gallery.model, &uid)
+28
View File
@@ -11,6 +11,33 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [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).
@@ -1011,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
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.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
+2 -1
View File
@@ -9,7 +9,7 @@
# Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux
pkgver=2.1.1
pkgver=2.2.1
pkgrel=1
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
arch=('x86_64')
@@ -20,6 +20,7 @@ makedepends=('cargo' 'git' 'gettext')
provides=('pdfs')
# Files-on-demand mount + keyring need these at runtime.
optdepends=('gnome-keyring: credential storage via Secret Service'
'ffmpeg: video thumbnails'
'xdg-utils: "Open folder" action from the tray'
'fzf: live search-as-you-type launcher (pdfs-prompt --fzf)'
'foot: terminal for pdfs-prompt --fzf started from a keybinding')
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux-bin
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
pkgver = 2.1.0
pkgver = 2.2.0
pkgrel = 1
url = https://github.com/narrrl/proton-drive-linux
arch = x86_64
@@ -14,26 +14,27 @@ pkgbase = proton-drive-for-linux-bin
depends = glibc
depends = hicolor-icon-theme
optdepends = perl-image-exiftool: thumbnails for camera RAW files
optdepends = ffmpeg: video thumbnails
optdepends = gnome-keyring: 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 = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-for-linux=2.1.0
provides = proton-drive-linux=2.1.0
provides = proton-drive-for-linux=2.2.0
provides = proton-drive-linux=2.2.0
provides = pdfs
conflicts = proton-drive-linux
conflicts = proton-drive-for-linux
conflicts = proton-drive-for-linux-git
options = !strip
options = !debug
source = proton-drive-linux-2.1.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v2.1.0/proton-drive-linux-2.1.0-x86_64.tar.gz
source = io.narl.proton-drive-linux.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.1.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.1.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.1.0/packaging/io.narl.proton-drive-linux.svg
source = proton-drive.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.1.0/packaging/proton-drive.service
source = LICENSE-2.1.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.1.0/LICENSE
sha256sums = dd8aba8e38f42386e44994476e9a949516580bd077be1d274c396eef411664ac
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.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.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.2.0/packaging/io.narl.proton-drive-linux.svg
source = proton-drive.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.0/packaging/proton-drive.service
source = LICENSE-2.2.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.0/LICENSE
sha256sums = 4d746d83abd5174acb324236b6146c083a43b8c25e8d30e31c2a6d5b594ba479
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux-bin
pkgver=2.1.0
pkgver=2.2.0
pkgrel=1
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
arch=('x86_64')
@@ -10,6 +10,7 @@ license=('MIT')
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
'hicolor-icon-theme')
optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
'ffmpeg: video thumbnails'
'gnome-keyring: credential storage over the Secret Service API'
'kwallet: credential storage over the Secret Service API'
'xdg-utils: "Open folder" action in the tray menu'
@@ -29,7 +30,7 @@ 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"
"proton-drive.service::$_raw/packaging/proton-drive.service"
"LICENSE-$pkgver::$_raw/LICENSE")
sha256sums=('dd8aba8e38f42386e44994476e9a949516580bd077be1d274c396eef411664ac'
sha256sums=('4d746d83abd5174acb324236b6146c083a43b8c25e8d30e31c2a6d5b594ba479'
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux-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
url = https://github.com/narrrl/proton-drive-linux
arch = x86_64
@@ -17,13 +17,14 @@ pkgbase = proton-drive-for-linux-git
depends = glibc
depends = hicolor-icon-theme
optdepends = perl-image-exiftool: thumbnails for camera RAW files
optdepends = ffmpeg: video thumbnails
optdepends = gnome-keyring: 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 = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
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-linux=2.0.0.r1.g56161f6
provides = proton-drive-for-linux=2.2.0.r1.g82e9654
provides = proton-drive-linux=2.2.0.r1.g82e9654
provides = pdfs
conflicts = proton-drive-linux
conflicts = proton-drive-for-linux
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux-git
pkgver=2.0.0.r1.g56161f6
pkgver=2.2.0.r1.g82e9654
pkgrel=1
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
arch=('x86_64')
@@ -11,6 +11,7 @@ depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
'hicolor-icon-theme')
makedepends=('cargo' 'git' 'gettext')
optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
'ffmpeg: video thumbnails'
'gnome-keyring: credential storage over the Secret Service API'
'kwallet: credential storage over the Secret Service API'
'xdg-utils: "Open folder" action in the tray menu'
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
pkgver = 2.1.0
pkgver = 2.2.0
pkgrel = 1
url = https://github.com/narrrl/proton-drive-linux
arch = x86_64
@@ -16,18 +16,19 @@ pkgbase = proton-drive-for-linux
depends = glibc
depends = hicolor-icon-theme
optdepends = perl-image-exiftool: thumbnails for camera RAW files
optdepends = ffmpeg: video thumbnails
optdepends = gnome-keyring: 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 = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-linux=2.1.0
provides = proton-drive-linux=2.2.0
provides = pdfs
conflicts = proton-drive-linux
conflicts = proton-drive-for-linux-bin
conflicts = proton-drive-for-linux-git
options = !lto
source = proton-drive-for-linux-2.1.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v2.1.0.tar.gz
sha256sums = 9ca0409636552dc6d158595396402fd0fe6d77fab2ac5f5239fc51d702b791cb
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 = 73f68211d008894c3254b5adca0acca7f9e1af90e89209c22ae0213aa75add4b
pkgname = proton-drive-for-linux
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux
pkgver=2.1.0
pkgver=2.2.0
pkgrel=1
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
arch=('x86_64')
@@ -11,6 +11,7 @@ depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
'hicolor-icon-theme')
makedepends=('cargo' 'gettext')
optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
'ffmpeg: video thumbnails'
'gnome-keyring: credential storage over the Secret Service API'
'kwallet: credential storage over the Secret Service API'
'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.
options=('!lto')
source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('9ca0409636552dc6d158595396402fd0fe6d77fab2ac5f5239fc51d702b791cb')
sha256sums=('73f68211d008894c3254b5adca0acca7f9e1af90e89209c22ae0213aa75add4b')
_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)
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
(cd "$pkg" && makepkg --printsrcinfo > .SRCINFO)
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