mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b81b76cb7 | ||
|
|
7470bd89f3 | ||
|
|
e6cb52339d | ||
|
|
912acc760a | ||
|
|
5109427c32 | ||
|
|
e2c4dac8df | ||
|
|
ebeb9cdbef | ||
|
|
82e96543ac | ||
|
|
96c77e96cb | ||
|
|
90e110e893 | ||
|
|
0b7236bd4a | ||
|
|
2fa5eaa75b | ||
|
|
dc9ea9f541 | ||
|
|
b13d9d9f38 |
No files matched your search
Generated
+4
-4
@@ -3449,7 +3449,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "2.1.1"
|
version = "2.2.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -3464,7 +3464,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "2.1.1"
|
version = "2.2.2"
|
||||||
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.1"
|
version = "2.2.2"
|
||||||
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.1"
|
version = "2.2.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
|
|||||||
+1
-1
@@ -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.1"
|
version = "2.2.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
|
|||||||
@@ -429,10 +429,12 @@ pub enum Request {
|
|||||||
/// Retry a backed-off queued op now instead of waiting out its backoff;
|
/// Retry a backed-off queued op now instead of waiting out its backoff;
|
||||||
/// every failed op when `id` is `None`. Replies with [`Response::Ok`].
|
/// every failed op when `id` is `None`. Replies with [`Response::Ok`].
|
||||||
RetryPendingOp { id: Option<i64> },
|
RetryPendingOp { id: Option<i64> },
|
||||||
/// List the `(sync-conflict …)` copies under My Files. Replies with
|
/// List the `(sync-conflict …)` copies under My Files and in synced
|
||||||
|
/// folders. Replies with
|
||||||
/// [`Response::Conflicts`].
|
/// [`Response::Conflicts`].
|
||||||
ListConflicts,
|
ListConflicts,
|
||||||
/// Resolve one conflict copy (`path` mountpoint-relative or absolute).
|
/// Resolve one conflict copy (`path` mountpoint-relative, or absolute for a
|
||||||
|
/// copy in a synced folder).
|
||||||
/// Everything removed goes to the Proton trash. Replies with
|
/// Everything removed goes to the Proton trash. Replies with
|
||||||
/// [`Response::Ok`].
|
/// [`Response::Ok`].
|
||||||
ResolveConflict { path: String, keep: ConflictKeep },
|
ResolveConflict { path: String, keep: ConflictKeep },
|
||||||
@@ -701,9 +703,10 @@ pub struct DeviceInfo {
|
|||||||
/// [`Response::Conflicts`]).
|
/// [`Response::Conflicts`]).
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct ConflictInfo {
|
pub struct ConflictInfo {
|
||||||
/// Mountpoint-relative path of the copy.
|
/// Mountpoint-relative path of the copy, or its absolute local path when it
|
||||||
|
/// sits in a synced folder.
|
||||||
pub path: String,
|
pub path: String,
|
||||||
/// Mountpoint-relative path of the file it is a copy of.
|
/// Path of the file it is a copy of, in the same form as `path`.
|
||||||
pub original_path: String,
|
pub original_path: String,
|
||||||
/// False when the original is gone and only the copy is left.
|
/// False when the original is gone and only the copy is left.
|
||||||
pub original_exists: bool,
|
pub original_exists: bool,
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the kept log already holds an entry of `kind` for `target`. Lets
|
||||||
|
/// the daemon report something once across restarts rather than once per
|
||||||
|
/// run, without keeping a second table in step with the log.
|
||||||
|
pub fn activity_has(&self, kind: ActivityKind, target: &str) -> Result<bool> {
|
||||||
|
let kind = serde_json::to_string(&kind)?;
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt =
|
||||||
|
conn.prepare("SELECT 1 FROM activity WHERE kind = ?1 AND target = ?2 LIMIT 1")?;
|
||||||
|
Ok(stmt.exists(params![kind, target])?)
|
||||||
|
}
|
||||||
|
|
||||||
/// The most recent activity, newest first, capped at `limit` entries. Rows
|
/// The most recent activity, newest first, capped at `limit` entries. Rows
|
||||||
/// whose stored `kind` no longer parses (written by an older build) are
|
/// whose stored `kind` no longer parses (written by an older build) are
|
||||||
/// skipped rather than failing the whole read.
|
/// skipped rather than failing the whole read.
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -1414,6 +1414,29 @@ fn activity_reads_back_newest_first() {
|
|||||||
assert_eq!(db.activity_list(1).unwrap().len(), 1);
|
assert_eq!(db.activity_list(1).unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn activity_has_finds_only_matching_kind_and_target() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.activity_add(&activity(
|
||||||
|
"a (sync-conflict 1).txt",
|
||||||
|
ActivityKind::Conflict,
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
db.activity_add(&activity("b.txt", ActivityKind::Upload, true))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
db.activity_has(ActivityKind::Conflict, "a (sync-conflict 1).txt")
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!db.activity_has(ActivityKind::Upload, "a (sync-conflict 1).txt")
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
assert!(!db.activity_has(ActivityKind::Conflict, "b.txt").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn activity_prunes_to_the_keep_limit() {
|
fn activity_prunes_to_the_keep_limit() {
|
||||||
let db = Db::open_in_memory().unwrap();
|
let db = Db::open_in_memory().unwrap();
|
||||||
@@ -4235,6 +4258,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!(
|
||||||
|
|||||||
@@ -14,15 +14,38 @@
|
|||||||
//! that is open, being written, or still owed an upload, for the same reason the
|
//! that is open, being written, or still owed an upload, for the same reason the
|
||||||
//! sweep does: the node is about to change under the decision.
|
//! sweep does: the node is about to change under the decision.
|
||||||
//!
|
//!
|
||||||
//! Only copies under My Files are handled here. A copy inside a mirror folder is
|
//! Copies under My Files are named by their mountpoint-relative path. Copies
|
||||||
//! a local file first; the sync engine owns it.
|
//! inside a synced folder are named by their absolute local path, so a resolve
|
||||||
|
//! request routes to the on-demand mount that serves them. A copy inside a
|
||||||
|
//! mirror folder is listed too, but it is a local file first: the sync engine
|
||||||
|
//! owns it, so it is resolved by editing the folder, not through here.
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use pdfs_core::control::{ConflictInfo, ConflictKeep};
|
use pdfs_core::control::{ConflictInfo, ConflictKeep};
|
||||||
use sweep::conflict_base_name;
|
use sweep::conflict_base_name;
|
||||||
|
|
||||||
impl Core {
|
impl Core {
|
||||||
/// Every conflict copy under My Files, with the file it is a copy of.
|
/// Where the user finds a conflict copy: its mountpoint-relative path under
|
||||||
|
/// My Files, or its absolute local path inside a synced folder. `None` for a
|
||||||
|
/// node no location of this machine shows, such as one in another
|
||||||
|
/// computer's device tree.
|
||||||
|
///
|
||||||
|
/// The sweep only flags what this can name, and the listing uses it too. A
|
||||||
|
/// flagged copy the listing cannot see reads as resolved in the activity
|
||||||
|
/// feed while it still sits in the folder (`docs/BUGS.md` B99).
|
||||||
|
pub(crate) fn conflict_path(&self, roots: &SearchRoots, uid: &NodeUid) -> Option<String> {
|
||||||
|
let uid = uid.to_string();
|
||||||
|
if let Ok(Some(path)) = self
|
||||||
|
.db
|
||||||
|
.path_relative_to(&self.primary_root_uid.to_string(), &uid)
|
||||||
|
{
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
let drive_path = self.db.node_path(&uid).ok().flatten()?;
|
||||||
|
roots.resolve(&drive_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every conflict copy this machine shows, with the file it is a copy of.
|
||||||
pub(crate) fn list_conflicts(&self) -> CoreResult<Vec<ConflictInfo>> {
|
pub(crate) fn list_conflicts(&self) -> CoreResult<Vec<ConflictInfo>> {
|
||||||
let stored = self
|
let stored = self
|
||||||
.db
|
.db
|
||||||
@@ -37,13 +60,13 @@ impl Core {
|
|||||||
for node in &nodes {
|
for node in &nodes {
|
||||||
by_parent_name.insert((node.parent_uid.as_ref(), node.name.as_str()), node);
|
by_parent_name.insert((node.parent_uid.as_ref(), node.name.as_str()), node);
|
||||||
}
|
}
|
||||||
let root = self.primary_root_uid.to_string();
|
let roots = self.search_roots();
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
for node in &nodes {
|
for node in &nodes {
|
||||||
let Some(base) = conflict_base_name(&node.name) else {
|
let Some(base) = conflict_base_name(&node.name) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Ok(Some(path)) = self.db.path_relative_to(&root, &node.uid.to_string()) else {
|
let Some(path) = self.conflict_path(&roots, &node.uid) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let original = by_parent_name
|
let original = by_parent_name
|
||||||
|
|||||||
@@ -185,6 +185,38 @@ fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor
|
|||||||
rel_to_mount(mountpoint, path).map(|rel| (core.clone(), rel))
|
rel_to_mount(mountpoint, path).map(|rel| (core.clone(), rel))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`route_to_mount`] for a conflict copy. The listing names a copy inside a
|
||||||
|
/// synced folder by its absolute local path (`docs/BUGS.md` B99), so the
|
||||||
|
/// resolve has to run against the on-demand mount serving it. A mirror folder
|
||||||
|
/// has no mount to run against: its copy is an ordinary local file.
|
||||||
|
fn route_conflict(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
|
||||||
|
let routed = route_to_mount(core, mountpoint, path).map_err(|e| {
|
||||||
|
let p = Path::new(path);
|
||||||
|
let mirrored = core
|
||||||
|
.db
|
||||||
|
.sync_folder_list()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.any(|folder| folder.mode == "mirror" && p.starts_with(&folder.local_path));
|
||||||
|
if mirrored {
|
||||||
|
CoreError::invalid(format!(
|
||||||
|
"{path} is in a mirrored folder; rename or delete the local file there"
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
e
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
if routed
|
||||||
|
.1
|
||||||
|
.components()
|
||||||
|
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
|
||||||
|
{
|
||||||
|
Ok(routed)
|
||||||
|
} else {
|
||||||
|
Err(CoreError::invalid(format!("{path} escapes the mountpoint")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Turn a CLI-supplied path into a mountpoint-relative path. An absolute path
|
/// Turn a CLI-supplied path into a mountpoint-relative path. An absolute path
|
||||||
/// must live under `mountpoint`; a relative path is taken as already relative to
|
/// must live under `mountpoint`; a relative path is taken as already relative to
|
||||||
/// the mount root.
|
/// the mount root.
|
||||||
@@ -1123,13 +1155,15 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Ok(items) => CtlResponse::Conflicts { items },
|
Ok(items) => CtlResponse::Conflicts { items },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::ResolveConflict { path, keep }) => match rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::ResolveConflict { path, keep }) => {
|
||||||
Ok(rel) => match core.resolve_conflict(&rel, &keep) {
|
match route_conflict(core, mountpoint, &path) {
|
||||||
|
Ok((core, rel)) => match core.resolve_conflict(&rel, &keep) {
|
||||||
Ok(message) => CtlResponse::Ok { message },
|
Ok(message) => CtlResponse::Ok { message },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
}
|
||||||
|
}
|
||||||
Ok(CtlRequest::ListPendingOps) => match core.pending_op_infos() {
|
Ok(CtlRequest::ListPendingOps) => match core.pending_op_infos() {
|
||||||
Ok(items) => CtlResponse::PendingOps { items },
|
Ok(items) => CtlResponse::PendingOps { items },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -558,9 +558,10 @@ struct Core {
|
|||||||
/// instead of polling or blocking for the whole run. See
|
/// instead of polling or blocking for the whole run. See
|
||||||
/// [`Core::await_trash_refresh`].
|
/// [`Core::await_trash_refresh`].
|
||||||
trash_progress: Arc<tokio::sync::Notify>,
|
trash_progress: Arc<tokio::sync::Notify>,
|
||||||
/// Conflict copies the sweep has already flagged as needing attention this
|
/// Conflict copies the sweep has already checked against the activity log
|
||||||
/// run, so a divergent `(sync-conflict …)` file is logged once rather than
|
/// this run, so each pass does not ask the database again. The log itself
|
||||||
/// on every sweep pass. See [`Core::run_conflict_sweep_loop`].
|
/// is what keeps a copy from being flagged again after a restart. See
|
||||||
|
/// [`Core::run_conflict_sweep_loop`] and `docs/BUGS.md` B99.
|
||||||
conflict_notified: Arc<Mutex<HashSet<NodeUid>>>,
|
conflict_notified: Arc<Mutex<HashSet<NodeUid>>>,
|
||||||
/// Whether the conflict sweep may actually trash the duplicates it finds, or
|
/// Whether the conflict sweep may actually trash the duplicates it finds, or
|
||||||
/// only report them. Resolved once at mount from config + environment
|
/// only report them. Resolved once at mount from config + environment
|
||||||
|
|||||||
+477
-51
@@ -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,14 +360,9 @@ 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());
|
|
||||||
let mut child = match command.spawn() {
|
|
||||||
Ok(child) => child,
|
|
||||||
Err(error) => {
|
|
||||||
if error.kind() == std::io::ErrorKind::NotFound {
|
|
||||||
EXIFTOOL_MISSING_WARNING.call_once(|| {
|
EXIFTOOL_MISSING_WARNING.call_once(|| {
|
||||||
warn!(
|
warn!(
|
||||||
"exiftool is not installed; camera RAW thumbnails are unavailable \
|
"exiftool is not installed; camera RAW thumbnails are unavailable \
|
||||||
@@ -295,43 +370,162 @@ fn exiftool_query(
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
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 || {
|
||||||
|
if video {
|
||||||
|
video_thumbnail(
|
||||||
|
&bytes,
|
||||||
|
&name,
|
||||||
|
&staging_dir,
|
||||||
|
OsStr::new("ffmpeg"),
|
||||||
|
FFMPEG_TIMEOUT,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
scale_thumbnail(&bytes, &name, &staging_dir)
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -109,6 +109,7 @@ impl Core {
|
|||||||
by_parent_name.insert((parent, node.name.as_str()), node);
|
by_parent_name.insert((parent, node.name.as_str()), node);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let roots = self.search_roots();
|
||||||
let online = self.online.load(std::sync::atomic::Ordering::Relaxed);
|
let online = self.online.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
for node in &nodes {
|
for node in &nodes {
|
||||||
if node.trashed || node.is_folder() {
|
if node.trashed || node.is_folder() {
|
||||||
@@ -132,7 +133,16 @@ impl Core {
|
|||||||
self.remove_conflict_copy(node, &base);
|
self.remove_conflict_copy(node, &base);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SweepAction::Flag(detail) => self.flag_conflict(&node.uid, &node.name, detail),
|
// Only flag what the conflict listing can show. Anything else
|
||||||
|
// would sit in the feed as "resolved" with no way to act on
|
||||||
|
// it (`docs/BUGS.md` B99).
|
||||||
|
SweepAction::Flag(detail) => {
|
||||||
|
if self.conflict_path(&roots, &node.uid).is_some() {
|
||||||
|
self.flag_conflict(&node.uid, &node.name, detail);
|
||||||
|
} else {
|
||||||
|
debug!(uid = %node.uid, name = %node.name, "conflict sweep: copy is outside every location; not flagged");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,12 +283,21 @@ impl Core {
|
|||||||
busy
|
busy
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Surface a conflict copy that needs the user's attention, at most once per
|
/// Surface a conflict copy that needs the user's attention, once. The
|
||||||
/// daemon run so the activity feed is not spammed each pass.
|
/// in-memory set spares the database each pass; the activity log itself is
|
||||||
|
/// what keeps a restart from logging the same copy again (`docs/BUGS.md`
|
||||||
|
/// B99). A copy's name carries its own timestamp, so the name identifies it.
|
||||||
fn flag_conflict(&self, uid: &NodeUid, name: &str, detail: String) {
|
fn flag_conflict(&self, uid: &NodeUid, name: &str, detail: String) {
|
||||||
if !self.conflict_notified.lock().insert(uid.clone()) {
|
if !self.conflict_notified.lock().insert(uid.clone()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
match self.db.activity_has(ActivityKind::Conflict, name) {
|
||||||
|
Ok(false) => {}
|
||||||
|
Ok(true) => return,
|
||||||
|
// Cannot tell: a repeat entry is harmless, a missing one hides a
|
||||||
|
// conflict.
|
||||||
|
Err(e) => warn!(%uid, name, error = ?e, "conflict sweep: activity check failed"),
|
||||||
|
}
|
||||||
debug!(%uid, name, detail, "conflict sweep: flagged divergent conflict copy");
|
debug!(%uid, name, detail, "conflict sweep: flagged divergent conflict copy");
|
||||||
self.log_activity(ActivityKind::Conflict, name, detail, false);
|
self.log_activity(ActivityKind::Conflict, name, detail, false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,4 +26,5 @@ fn main() {
|
|||||||
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
|
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
|
||||||
println!("cargo:rerun-if-changed=resources/icons");
|
println!("cargo:rerun-if-changed=resources/icons");
|
||||||
println!("cargo:rerun-if-changed=resources/style.css");
|
println!("cargo:rerun-if-changed=resources/style.css");
|
||||||
|
println!("cargo:rerun-if-changed=../../packaging/io.narl.proton-drive-linux.svg");
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,9 @@
|
|||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-upload-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-upload-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-offline-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-offline-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-online-only-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-online-only-symbolic.svg</file>
|
||||||
|
<!-- The app icon, from the one file packaging installs, so the in-app brand
|
||||||
|
matches the launcher and resolves even when nothing is installed. -->
|
||||||
|
<file alias="scalable/apps/io.narl.proton-drive-linux.svg" preprocess="xml-stripblanks">../../../packaging/io.narl.proton-drive-linux.svg</file>
|
||||||
</gresource>
|
</gresource>
|
||||||
<!-- The app stylesheet, loaded once at startup. Colours come from the
|
<!-- The app stylesheet, loaded once at startup. Colours come from the
|
||||||
libadwaita palette so the app follows the system accent, light/dark and
|
libadwaita palette so the app follows the system accent, light/dark and
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
|
|
||||||
/* Brand */
|
/* Brand */
|
||||||
.brand-title { font-size: 1.6rem; font-weight: 800; }
|
.brand-title { font-size: 1.6rem; font-weight: 800; }
|
||||||
.brand-icon { color: #6d4aff; }
|
|
||||||
|
|
||||||
/* Sidebar footer */
|
/* Sidebar footer */
|
||||||
.sidebar-footer { padding: 6px 12px 12px 12px; }
|
.sidebar-footer { padding: 6px 12px 12px 12px; }
|
||||||
|
|||||||
@@ -884,8 +884,8 @@ fn build_sidebar(footer: >k4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let brand = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
let brand = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||||
let icon = gtk4::Image::from_icon_name("folder-remote-symbolic");
|
let icon = gtk4::Image::from_icon_name(APP_ID);
|
||||||
icon.add_css_class("brand-icon");
|
icon.set_pixel_size(24);
|
||||||
brand.append(&icon);
|
brand.append(&icon);
|
||||||
let name = gtk4::Label::new(Some("Proton Drive"));
|
let name = gtk4::Label::new(Some("Proton Drive"));
|
||||||
name.add_css_class("heading");
|
name.add_css_class("heading");
|
||||||
@@ -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 "&".
|
||||||
|
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");
|
||||||
|
|||||||
@@ -52,9 +52,8 @@ pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) {
|
|||||||
login_status.add_css_class("dim-label");
|
login_status.add_css_class("dim-label");
|
||||||
|
|
||||||
let header = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
let header = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||||
let logo = gtk4::Image::from_icon_name("folder-remote-symbolic");
|
let logo = gtk4::Image::from_icon_name(APP_ID);
|
||||||
logo.set_pixel_size(64);
|
logo.set_pixel_size(96);
|
||||||
logo.add_css_class("brand-icon");
|
|
||||||
let title = gtk4::Label::new(Some("Proton Drive"));
|
let title = gtk4::Label::new(Some("Proton Drive"));
|
||||||
title.add_css_class("brand-title");
|
title.add_css_class("brand-title");
|
||||||
header.append(&logo);
|
header.append(&logo);
|
||||||
|
|||||||
@@ -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: >k4::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() {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -12,6 +12,42 @@ Conventions:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## B99 — Conflict copies in synced folders are logged again on every start and shown as "Resolved"
|
||||||
|
|
||||||
|
**Status:** Fixed (unverified). No daemon carrying the change has been restarted against the
|
||||||
|
affected copies yet.
|
||||||
|
**Found:** 2026-09-27. The Activity page listed the same six conflict copies every day, e.g.
|
||||||
|
`Conflict [EMBER] Oshi no Ko S2 - 12 (sync-conflict 1786815864).mkv` with `Differs from … · 24 times
|
||||||
|
since Sep 20, 13:13 · Resolved`. `find` showed all six copies still on disk, in `~/Videos` and
|
||||||
|
`~/Documents`. Both are on-demand sync folders. `pdfs conflicts` answered `No conflicts.`. The
|
||||||
|
journal showed the daemon mounting at 17:29:33, and the entries were stamped 17:30. That is the
|
||||||
|
sweep's 30-second warmup.
|
||||||
|
|
||||||
|
**Where:** `crates/pdfs-fuse/src/sweep.rs` (`flag_conflict`, `sweep_conflicts_once`),
|
||||||
|
`crates/pdfs-fuse/src/conflicts.rs` (`list_conflicts`), `crates/pdfs-fuse/src/control.rs`
|
||||||
|
(`ResolveConflict`).
|
||||||
|
|
||||||
|
**Cause.** There were two faults.
|
||||||
|
|
||||||
|
- The sweep kept its "already flagged" set only in memory. Every daemon start forgot it, so the
|
||||||
|
first pass logged every open conflict again.
|
||||||
|
- The sweep flags copies anywhere in the account's own tree. `list_conflicts` only reported copies
|
||||||
|
below My Files, and resolving only accepted paths under the primary mount. A copy in a synced
|
||||||
|
folder was logged but never listed, so the GUI read it as resolved. It also could not be
|
||||||
|
resolved from the GUI or the CLI.
|
||||||
|
|
||||||
|
**Fix.** `Core::conflict_path` names a copy by its mountpoint-relative path under My Files, or
|
||||||
|
by its absolute local path inside a synced folder. It reuses the roots that search already
|
||||||
|
resolves hits through. The listing uses it, and the sweep flags only copies it can name. So the
|
||||||
|
feed never holds a conflict the listing cannot answer for. `ResolveConflict` routes an absolute
|
||||||
|
path to the on-demand mount that serves it. For a copy in a mirror folder, it says to edit the
|
||||||
|
local file instead. Before logging, `flag_conflict` checks the activity log
|
||||||
|
(`Db::activity_has`), so a restart does not log the same copy again. The copy's name carries its
|
||||||
|
own timestamp, so the name identifies the copy. No schema change.
|
||||||
|
Regression test: `activity_has_finds_only_matching_kind_and_target`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## B98 — A superseded upload spins its drain thread forever and shows as "Uploading" at 0 bytes
|
## 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
|
**Status:** Fixed (unverified) — the daemon carrying the change has not been driven against a
|
||||||
|
|||||||
@@ -11,6 +11,42 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.2.2] — 2026-09-27
|
||||||
|
|
||||||
|
Schema: **32** (unchanged).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Conflicts in synced folders.** A conflict copy inside a synced folder is now listed under
|
||||||
|
Needs attention and can be resolved there or with `pdfs conflicts`. It used to be logged again
|
||||||
|
after every restart and marked "Resolved" while it was still on disk (B99).
|
||||||
|
|
||||||
|
## [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
|
## [2.1.1] — 2026-09-23
|
||||||
|
|
||||||
Schema: **31** (unchanged).
|
Schema: **31** (unchanged).
|
||||||
@@ -1011,6 +1047,8 @@ 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.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.2.2
|
||||||
|
[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
@@ -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.1
|
pkgver=2.2.2
|
||||||
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')
|
||||||
|
|||||||
@@ -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.1.0
|
pkgver = 2.2.1
|
||||||
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,26 +14,27 @@ 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.1.0
|
provides = proton-drive-for-linux=2.2.1
|
||||||
provides = proton-drive-linux=2.1.0
|
provides = proton-drive-linux=2.2.1
|
||||||
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.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 = proton-drive-linux-2.2.1-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v2.2.1/proton-drive-linux-2.2.1-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.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.1/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-tray.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.1/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 = io.narl.proton-drive-linux.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.1/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 = proton-drive.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.1/packaging/proton-drive.service
|
||||||
source = LICENSE-2.1.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.1.0/LICENSE
|
source = LICENSE-2.2.1::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.1/LICENSE
|
||||||
sha256sums = dd8aba8e38f42386e44994476e9a949516580bd077be1d274c396eef411664ac
|
sha256sums = 69f0d6996feb95b18d81bb96e9373c61fddb64ab17b50c94cd83ef8d5e0a1425
|
||||||
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
||||||
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
||||||
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
||||||
|
|||||||
@@ -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.1.0
|
pkgver=2.2.1
|
||||||
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,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"
|
"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=('dd8aba8e38f42386e44994476e9a949516580bd077be1d274c396eef411664ac'
|
sha256sums=('69f0d6996feb95b18d81bb96e9373c61fddb64ab17b50c94cd83ef8d5e0a1425'
|
||||||
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
||||||
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
||||||
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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.1.r0.g5109427
|
||||||
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
|
||||||
@@ -17,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.1.r0.g5109427
|
||||||
provides = proton-drive-linux=2.0.0.r1.g56161f6
|
provides = proton-drive-linux=2.2.1.r0.g5109427
|
||||||
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.1.r0.g5109427
|
||||||
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,6 +1,6 @@
|
|||||||
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.1.0
|
pkgver = 2.2.1
|
||||||
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
|
||||||
@@ -16,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.1.0
|
provides = proton-drive-linux=2.2.1
|
||||||
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.1.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v2.1.0.tar.gz
|
source = proton-drive-for-linux-2.2.1.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v2.2.1.tar.gz
|
||||||
sha256sums = 9ca0409636552dc6d158595396402fd0fe6d77fab2ac5f5239fc51d702b791cb
|
sha256sums = 4635716908232896bfb0bf16d0f10300dc2eb59c27c7693607a21eabcac2edff
|
||||||
|
|
||||||
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.1.0
|
pkgver=2.2.1
|
||||||
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=('9ca0409636552dc6d158595396402fd0fe6d77fab2ac5f5239fc51d702b791cb')
|
sha256sums=('4635716908232896bfb0bf16d0f10300dc2eb59c27c7693607a21eabcac2edff')
|
||||||
|
|
||||||
_srcname="proton-drive-linux-$pkgver"
|
_srcname="proton-drive-linux-$pkgver"
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+221
-177
File diff suppressed because it is too large.
Load diff
+213
-178
File diff suppressed because it is too large.
Load diff
+222
-177
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user