Compare commits

...
10 Commits
Author SHA1 Message Date
nvrl 661ae3cf7a fixed incorrect versioning for rpm build 2026-07-20 17:06:36 +02:00
nvrl f803bf3cd7 fixed github ci 2026-07-20 15:06:17 +02:00
nvrl 084740ad95 version bump to 0.6.2 2026-07-20 14:54:13 +02:00
nvrl 65eed0cc72 Merge pull request #12 from narrrl/release/0.6.2
release/0.6.2
2026-07-20 14:53:06 +02:00
nvrl 1e0e284d5f per-chunk wakeup, B13 + B14 2026-07-20 14:52:31 +02:00
nvrl f0fbd0775c fixed long getattr load times with ls -l 2026-07-20 13:37:59 +02:00
nvrl d25a573e8f fixed mount-kind correlation 2026-07-20 00:09:00 +02:00
nvrl eafe1307b8 Merge pull request #11 from narrrl/fix/return-prompt
Fix/return prompt
2026-07-19 23:53:04 +02:00
nvrl 056ae0463f Merge pull request #10 from narrrl/fix/capture #
added webgtk recaptcha
2026-07-19 23:52:39 +02:00
nvrl b2b0fe7b99 fixed return key not opening file 2026-07-19 23:39:24 +02:00
15 changed files with 2425 additions and 58 deletions

No files matched your search

+4 -1
View File
@@ -26,6 +26,7 @@ jobs:
libfuse3-dev \ libfuse3-dev \
libgtk-4-dev \ libgtk-4-dev \
libadwaita-1-dev \ libadwaita-1-dev \
libwebkitgtk-6.0-dev \
libsecret-1-dev \ libsecret-1-dev \
libdbus-1-dev \ libdbus-1-dev \
desktop-file-utils \ desktop-file-utils \
@@ -98,7 +99,7 @@ jobs:
Priority: optional Priority: optional
Architecture: amd64 Architecture: amd64
Maintainer: Nils Pukropp <contact@narl.io> Maintainer: Nils Pukropp <contact@narl.io>
Depends: fuse3, libgtk-4-1 (>= 4.12.0), libadwaita-1-0 (>= 1.5.0), libsecret-1-0 Depends: fuse3, libgtk-4-1 (>= 4.12.0), libadwaita-1-0 (>= 1.5.0), libsecret-1-0, libwebkitgtk-6.0-4
Installed-Size: ${INSTALLED_SIZE} Installed-Size: ${INSTALLED_SIZE}
Description: Proton Drive client for Linux Description: Proton Drive client for Linux
Proton Drive client for Linux featuring: Proton Drive client for Linux featuring:
@@ -120,8 +121,10 @@ jobs:
# Build RPM package # Build RPM package
rpmbuild -bb \ rpmbuild -bb \
--nodeps \
--define "_topdir $PWD/rpmbuild" \ --define "_topdir $PWD/rpmbuild" \
--define "_sourcedir $PWD" \ --define "_sourcedir $PWD" \
--define "git_dir $PWD" \
--define "version ${RPM_VERSION}" \ --define "version ${RPM_VERSION}" \
--define "_dbpath $PWD/rpmbuild/db" \ --define "_dbpath $PWD/rpmbuild/db" \
packaging/proton-drive-linux.spec packaging/proton-drive-linux.spec
Generated
+4 -4
View File
@@ -3518,7 +3518,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]] [[package]]
name = "pdfs-cli" name = "pdfs-cli"
version = "0.6.1" version = "0.6.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap 4.6.2", "clap 4.6.2",
@@ -3533,7 +3533,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-core" name = "pdfs-core"
version = "0.6.1" version = "0.6.2"
dependencies = [ dependencies = [
"directories", "directories",
"ignore", "ignore",
@@ -3552,7 +3552,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-fuse" name = "pdfs-fuse"
version = "0.6.1" version = "0.6.2"
dependencies = [ dependencies = [
"fuser", "fuser",
"image", "image",
@@ -3568,7 +3568,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-gui" name = "pdfs-gui"
version = "0.6.1" version = "0.6.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"] members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package] [workspace.package]
version = "0.6.1" version = "0.6.2"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"] authors = ["Nils Pukropp <contact@narl.io>"]
+4 -2
View File
@@ -11,6 +11,7 @@ A fast, unofficial Proton Drive client for Linux. This client features an advanc
- **Fuzzy Search Launcher Prompt (HUD)**: A Google Drive-style search launcher (`pdfs-prompt`) for finding and opening files or folders instantly, ideal for binding to a system-wide hotkey. - **Fuzzy Search Launcher Prompt (HUD)**: A Google Drive-style search launcher (`pdfs-prompt`) for finding and opening files or folders instantly, ideal for binding to a system-wide hotkey.
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages. - **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages.
- **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI). - **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI).
- **Human Verification (CAPTCHA) Recovery**: Detects sign-in gates (VPN/new IP challenges) and launches an embedded `WebKitWebView` dialog to safely complete the challenge, transparently retrying authentication with the earned token.
## Performance & Caching ## Performance & Caching
@@ -54,20 +55,21 @@ sudo apt-get install -y \
libfuse3-dev \ libfuse3-dev \
libgtk-4-dev \ libgtk-4-dev \
libadwaita-1-dev \ libadwaita-1-dev \
libwebkitgtk-6.0-dev \
libsecret-1-dev \ libsecret-1-dev \
libdbus-1-dev libdbus-1-dev
``` ```
### Arch Linux ### Arch Linux
```bash ```bash
sudo pacman -S --needed pkgconf fuse3 gtk4 libadwaita libsecret dbus sudo pacman -S --needed pkgconf fuse3 gtk4 libadwaita libsecret dbus webkitgtk-6.0
``` ```
### Fedora (44+) ### Fedora (44+)
```bash ```bash
sudo dnf install -y \ sudo dnf install -y \
pkgconf-pkg-config fuse3-devel gtk4-devel libadwaita-devel \ pkgconf-pkg-config fuse3-devel gtk4-devel libadwaita-devel \
libsecret-devel dbus-devel glib2-devel cargo rust webkitgtk6.0-devel libsecret-devel dbus-devel glib2-devel cargo rust
``` ```
Runtime extras (pick your desktop): Runtime extras (pick your desktop):
+30 -1
View File
@@ -9,7 +9,7 @@ use super::Db;
use crate::Result; use crate::Result;
/// Current schema version. Bump on every forward migration added below. /// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 14; pub(super) const SCHEMA_VERSION: i64 = 15;
impl Db { impl Db {
pub(super) fn migrate(&self) -> Result<()> { pub(super) fn migrate(&self) -> Result<()> {
@@ -78,6 +78,9 @@ impl Db {
if current < 14 { if current < 14 {
tx.execute_batch(MIGRATION_V14)?; tx.execute_batch(MIGRATION_V14)?;
} }
if current < 15 {
tx.execute_batch(MIGRATION_V15)?;
}
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",
@@ -340,3 +343,29 @@ ALTER TABLE photos ADD COLUMN kind INTEGER NOT NULL DEFAULT 0;
const MIGRATION_V14: &str = " const MIGRATION_V14: &str = "
CREATE INDEX cache_entries_lru ON cache_entries(kind, last_accessed); CREATE INDEX cache_entries_lru ON cache_entries(kind, last_accessed);
"; ";
/// Schema v15: key the search index by rowid so a row can be replaced without
/// scanning the whole index (B12).
///
/// v3 built `nodes_fts` with the node's `uid` as an `UNINDEXED` column and
/// `upsert_nodes` refreshed a row with `DELETE FROM nodes_fts WHERE uid = ?`.
/// `UNINDEXED` means exactly what it says — the column is retrievable but not
/// searchable — so that predicate could only ever be a full scan of the index
/// (`EXPLAIN QUERY PLAN`: `SCAN nodes_fts VIRTUAL TABLE`). Every node written
/// scanned every node indexed: measured at 6.5 ms per node against a 17k-node
/// index, about half the cost of a cold listing, and *growing with the size of
/// the account* rather than the size of the listing.
///
/// FTS5 deletes by `rowid` efficiently, and `nodes` is an ordinary rowid table
/// (`uid` is its `TEXT PRIMARY KEY`, not `WITHOUT ROWID`), so its rowid is a
/// stable per-node key that `ON CONFLICT DO UPDATE` preserves. Rebuild the index
/// keyed by it and drop the `uid` column: searches now join on `rowid`, which is
/// also a cheaper join than the old one on a TEXT uid.
const MIGRATION_V15: &str = "
DROP TABLE IF EXISTS nodes_fts;
CREATE VIRTUAL TABLE nodes_fts USING fts5(
name, tokenize='trigram'
);
INSERT INTO nodes_fts (rowid, name)
SELECT rowid, name FROM nodes WHERE trashed = 0;
";
+29 -5
View File
@@ -70,11 +70,22 @@ impl Db {
// FTS5 has no UPSERT, so refresh the row by delete-then-insert. // FTS5 has no UPSERT, so refresh the row by delete-then-insert.
// Trashed nodes are kept out of the index entirely so they never // Trashed nodes are kept out of the index entirely so they never
// surface in search results. // surface in search results.
tx.execute("DELETE FROM nodes_fts WHERE uid = ?1", params![uid])?; //
// Keyed by the node's rowid, never by its uid: the uid is not an
// indexed FTS column, so deleting by it scans the entire index once
// per node written (B12). The rowid is stable across the upsert
// above — `ON CONFLICT DO UPDATE` keeps the existing row — so it is
// the same key before and after.
let rowid: i64 = tx.query_row(
"SELECT rowid FROM nodes WHERE uid = ?1",
params![uid],
|row| row.get(0),
)?;
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
if !node.trashed { if !node.trashed {
tx.execute( tx.execute(
"INSERT INTO nodes_fts (uid, name) VALUES (?1, ?2)", "INSERT INTO nodes_fts (rowid, name) VALUES (?1, ?2)",
params![uid, node.name], params![rowid, node.name],
)?; )?;
} }
} }
@@ -88,8 +99,21 @@ impl Db {
let uid = uid.to_string(); let uid = uid.to_string();
let mut conn = self.conn.lock(); let mut conn = self.conn.lock();
let tx = conn.transaction()?; let tx = conn.transaction()?;
// Read the rowid before dropping the node: it is the search index's key
// (B12), and it is gone once the row is. A node with no row leaves
// nothing to unindex — the index is keyed off `nodes`, so it cannot
// hold an entry the table never had.
let rowid: Option<i64> = tx
.query_row(
"SELECT rowid FROM nodes WHERE uid = ?1",
params![uid],
|row| row.get(0),
)
.optional()?;
tx.execute("DELETE FROM nodes WHERE uid = ?1", params![uid])?; tx.execute("DELETE FROM nodes WHERE uid = ?1", params![uid])?;
tx.execute("DELETE FROM nodes_fts WHERE uid = ?1", params![uid])?; if let Some(rowid) = rowid {
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
}
tx.commit()?; tx.commit()?;
Ok(()) Ok(())
} }
@@ -123,7 +147,7 @@ impl Db {
.join(" AND "); .join(" AND ");
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT n.node_json, n.uid "SELECT n.node_json, n.uid
FROM nodes_fts f JOIN nodes n ON n.uid = f.uid FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
ORDER BY f.rank LIMIT ?2", ORDER BY f.rank LIMIT ?2",
)?; )?;
+72
View File
@@ -564,6 +564,78 @@ fn search_drops_fts_row_on_delete_and_trash() {
assert_eq!(db.search("unique", 10).unwrap().len(), 0); assert_eq!(db.search("unique", 10).unwrap().len(), 0);
} }
/// Renaming re-indexes the node, which is the case delete-by-rowid has to get
/// right: the *old* name must leave the index. A stale row here would be
/// invisible in every other test — the new name is findable either way.
#[test]
fn renaming_a_node_replaces_the_name_in_the_search_index() {
let db = Db::open_in_memory().unwrap();
db.upsert_node(&folder("root", None, "My Files")).unwrap();
db.upsert_node(&file("f1", "root", "beforename.txt", 1))
.unwrap();
assert_eq!(db.search("beforename", 10).unwrap().len(), 1);
db.upsert_node(&file("f1", "root", "aftername.txt", 1))
.unwrap();
assert_eq!(
db.search("aftername", 10).unwrap().len(),
1,
"the new name must be searchable"
);
assert_eq!(
db.search("beforename", 10).unwrap().len(),
0,
"the old name must have left the index, not merely been added alongside"
);
}
/// B12: the cost of writing a listing must not scale with how much is already
/// indexed. The old `DELETE FROM nodes_fts WHERE uid = ?` scanned the whole
/// index once per node written, so a large account paid more to write the *same*
/// folder — measured at 6.5 ms per node against a 17k-node index on the live DB.
///
/// Written as a ratio between two index sizes rather than an absolute time, so
/// it fails on the scaling regression and not on a slow machine.
#[test]
fn writing_nodes_does_not_scale_with_the_size_of_the_index() {
use std::time::Instant;
/// Fill `db` with `n` indexed nodes, then time re-writing a fixed batch.
fn cost_against_index_of(n: usize) -> std::time::Duration {
let db = Db::open_in_memory().unwrap();
db.upsert_node(&folder("root", None, "My Files")).unwrap();
let filler: Vec<_> = (0..n)
.map(|i| file(&format!("bg{i}"), "root", &format!("background{i}.txt"), 1))
.collect();
db.upsert_nodes(&filler).unwrap();
// The batch under test is identical in both runs; only the amount of
// already-indexed data around it differs.
let batch: Vec<_> = (0..200)
.map(|i| file(&format!("m{i}"), "root", &format!("measured{i}.txt"), 1))
.collect();
db.upsert_nodes(&batch).unwrap();
let t = Instant::now();
db.upsert_nodes(&batch).unwrap();
t.elapsed()
}
let small = cost_against_index_of(200);
let large = cost_against_index_of(5000);
// Calibration, measured while writing this: over the same 25× growth in
// index size, the old full-scan path cost 6.4× more (46 ms → 294 ms) while
// the rowid path costs 1.04× (39 ms → 41 ms). 3× sits well clear of both.
println!("B12: 200-node write — against 200 indexed {small:?}, against 5000 {large:?}");
assert!(
large < small * 3,
"writing a listing got dramatically more expensive as the index grew, \
which is the B12 full-scan regression; small={small:?} large={large:?}"
);
}
fn activity(target: &str, kind: ActivityKind, ok: bool) -> ActivityEntry { fn activity(target: &str, kind: ActivityKind, ok: bool) -> ActivityEntry {
ActivityEntry { ActivityEntry {
time: 1700, time: 1700,
+15 -3
View File
@@ -20,6 +20,7 @@ use pdfs_core::db::{StoredDevice, StoredSyncFolder};
use pdfs_core::{CoreError, CoreResult}; use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid}; use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid};
use proton_drive_rs::{DeviceType, Node}; use proton_drive_rs::{DeviceType, Node};
use std::sync::OnceLock;
use tracing::{info, warn}; use tracing::{info, warn};
use super::sync::{self, base_name}; use super::sync::{self, base_name};
@@ -394,6 +395,12 @@ impl Core {
next_fh: 1, next_fh: 1,
db: self.db.clone(), db: self.db.clone(),
})); }));
// A fresh inode space needs a fresh notification channel: this fork's
// session is the only one that knows these inodes, so it must be the one
// notified about them. Filled in by `spawn_ondemand_mount`.
fork.notifier = Arc::new(OnceLock::new());
// Size upgrades are keyed by inode, which is per-fork too.
fork.size_upgrades = Arc::new(Mutex::new(HashMap::new()));
fork fork
} }
@@ -639,10 +646,15 @@ impl Core {
MountOption::Subtype("protondrive".to_string()), MountOption::Subtype("protondrive".to_string()),
MountOption::DefaultPermissions, MountOption::DefaultPermissions,
]; ];
let fs = ProtonFs::new(self.fork_state(), root); let core = self.fork_state();
Session::new(fs, local, &config) let fs = ProtonFs::new(core.clone(), root);
let session = Session::new(fs, local, &config)
.and_then(|s| s.spawn()) .and_then(|s| s.spawn())
.map_err(|e| CoreError::internal(format!("mount {}: {e}", local.display()))) .map_err(|e| CoreError::internal(format!("mount {}: {e}", local.display())))?;
// Hand this fork its own session's notification channel, so a background
// size upgrade can drop the kernel's cached attrs for these inodes.
let _ = core.notifier.set(session.notifier());
Ok(session)
} }
/// Re-establish FUSE mounts for folders left in `ondemand` mode across a daemon /// Re-establish FUSE mounts for folders left in `ondemand` mode across a daemon
File diff suppressed because it is too large. Load diff
+17 -2
View File
@@ -70,7 +70,7 @@ pub(crate) fn prompt_human_verification(
let token_tx = Rc::new(RefCell::new(Some(token_tx))); let token_tx = Rc::new(RefCell::new(Some(token_tx)));
let tx = token_tx.clone(); let tx = token_tx.clone();
let dlg = dialog.clone(); let dlg_weak = dialog.downgrade();
content.connect_script_message_received(Some("hv"), move |_, value| { content.connect_script_message_received(Some("hv"), move |_, value| {
let Some(token) = extract_token(&value.to_str()) else { let Some(token) = extract_token(&value.to_str()) else {
// Every `postMessage` on the page reaches here, most of them the // Every `postMessage` on the page reaches here, most of them the
@@ -81,7 +81,9 @@ pub(crate) fn prompt_human_verification(
if let Some(tx) = tx.borrow_mut().take() { if let Some(tx) = tx.borrow_mut().take() {
let _ = tx.send(token); let _ = tx.send(token);
} }
if let Some(dlg) = dlg_weak.upgrade() {
dlg.close(); dlg.close();
}
}); });
// Covers the close button, Escape, and the user giving up. // Covers the close button, Escape, and the user giving up.
@@ -104,7 +106,14 @@ pub(crate) fn prompt_human_verification(
/// unrecognised shape as success would hand the API an empty token and fail the /// unrecognised shape as success would hand the API an empty token and fail the
/// login with a confusing error. /// login with a confusing error.
fn extract_token(raw: &str) -> Option<String> { fn extract_token(raw: &str) -> Option<String> {
let value: serde_json::Value = serde_json::from_str(raw).ok()?; let mut value: serde_json::Value = serde_json::from_str(raw).ok()?;
// If the message was stringified twice (e.g. raw is a double-quoted JSON string),
// parse the inner string.
if let Some(inner) = value.as_str() {
if let Ok(parsed) = serde_json::from_str(inner) {
value = parsed;
}
}
// The payload is nested under `payload` and the message names itself in // The payload is nested under `payload` and the message names itself in
// `type`; both spellings of the success type have shipped. // `type`; both spellings of the success type have shipped.
let kind = value.get("type")?.as_str()?; let kind = value.get("type")?.as_str()?;
@@ -133,6 +142,12 @@ mod tests {
assert_eq!(extract_token(raw).as_deref(), Some("tok-1")); assert_eq!(extract_token(raw).as_deref(), Some("tok-1"));
} }
#[test]
fn a_double_serialized_completion_message_yields_its_token() {
let raw = r#""{\"type\":\"HUMAN_VERIFICATION_SUCCESS\",\"payload\":{\"token\":\"tok-1\",\"type\":\"captcha\"}}""#;
assert_eq!(extract_token(raw).as_deref(), Some("tok-1"));
}
/// The page posts plenty that is not a completion; none of it may be /// The page posts plenty that is not a completion; none of it may be
/// mistaken for one, or the login retries with a garbage token. /// mistaken for one, or the login retries with a garbage token.
#[test] #[test]
+20 -6
View File
@@ -515,12 +515,10 @@ fn build_window(app: &adw::Application) {
ui_key.cycle_filter(back); ui_key.cycle_filter(back);
glib::Propagation::Stop glib::Propagation::Stop
} }
gtk4::gdk::Key::Return | gtk4::gdk::Key::KP_Enter => { // Return is deliberately absent: `GtkText` binds it to `activate`
// Fall back to the top row when nothing is explicitly selected // and consumes it, so a bubble-phase controller on the window never
// (e.g. results haven't rendered yet); `open` guards the rest. // sees it while the entry has focus (which is always, here). It is
ui_key.open(ui_key.cursor.get().unwrap_or(0), &window_key); // handled on the entry's `activate` signal instead — see below.
glib::Propagation::Stop
}
_ => glib::Propagation::Proceed, _ => glib::Propagation::Proceed,
} }
}); });
@@ -528,6 +526,22 @@ fn build_window(app: &adw::Application) {
// pointer has moved focus into it. // pointer has moved focus into it.
window.add_controller(keys); window.add_controller(keys);
// Enter opens the row under the cursor. This lives on the entry rather than
// with the other keys on the window because `GtkText` claims Return for its
// own `activate` binding, so a bubble-phase controller upstream of the
// focused entry is never reached. Enter with focus in the list is covered by
// `row_activated` above.
let ui_activate = ui.clone();
let window_activate = window.clone();
entry.connect_activate(move |_| {
if ui_activate.opening.get() {
return;
}
// Fall back to the top row when nothing is explicitly selected (e.g.
// results haven't rendered yet); `open` guards the rest.
ui_activate.open(ui_activate.cursor.get().unwrap_or(0), &window_activate);
});
let debounce: Rc<RefCell<Option<glib::SourceId>>> = Rc::new(RefCell::new(None)); let debounce: Rc<RefCell<Option<glib::SourceId>>> = Rc::new(RefCell::new(None));
let ui_changed = ui.clone(); let ui_changed = ui.clone();
entry.connect_changed(move |entry| { entry.connect_changed(move |entry| {
+60
View File
@@ -284,3 +284,63 @@ See [RECOVERY.md](RECOVERY.md) for what a lost machine means for the plaintext d
- Treat the cache and state directories as being as sensitive as the Drive contents themselves. - Treat the cache and state directories as being as sensitive as the Drive contents themselves.
- Multi-user machines warrant checking `~/.cache` and `~/.local/state` are `0700` until §8.5 is addressed. - Multi-user machines warrant checking `~/.cache` and `~/.local/state` are `0700` until §8.5 is addressed.
- Purging the cache (`pdfs` settings, or `PurgeCache` over IPC) removes content but **not** `cache.db`, and deliberately never removes undrained `staging/` or `recovery/` blobs. - Purging the cache (`pdfs` settings, or `PurgeCache` over IPC) removes content but **not** `cache.db`, and deliberately never removes undrained `staging/` or `recovery/` blobs.
---
## 9. Human Verification (CAPTCHA) Flow
When logging in from an unfamiliar IP address or VPN, the Proton API may gate the sign-in with a human verification challenge (CAPTCHA). This client handles this asynchronously and interactively.
### 9.1 Sequence of Verification and Re-Authentication
```mermaid
sequenceDiagram
autonumber
actor User as User (GUI)
participant Core as pdfs-core (Auth)
participant UI as pdfs-gui (Login Page)
participant Web as WebKitWebView Dialog
participant API as Proton API Server
User->>UI: Enter credentials & click Sign In
UI->>Core: login_interactive()
Core->>API: auth/v4 (Initial SRP Handshake)
API-->>Core: HTTP 422 (Error 9001: CAPTCHA Challenge URL)
Core->>UI: Error::HumanVerificationRequired(hv)
UI->>UI: Block login thread, dispatch to GTK main loop
UI->>Web: Create dialog & load verification URL
Note over User, Web: User completes CAPTCHA in embedded WebView
Web->>UI: window.postMessage(HUMAN_VERIFICATION_SUCCESS)
UI->>UI: Extract token (with double-serialization safety)
UI->>UI: Close dialog & send token to blocked login thread
UI->>Core: login_verified(with verification token)
Core->>API: auth/v4 (SRP retry with x-pm-human-verification-token)
API-->>Core: Returns session tokens
Core-->>UI: Login Successful
```
### 9.2 Key Technical Design Decisions
1. **Weak Reference UI Binding:** To prevent memory leaks and strong reference cycles between the parent dialog, the child `WebKitWebView`, the script message manager, and the connection callback, the dialog is downgraded to a `WeakRef` inside the callback:
```rust
let dlg_weak = dialog.downgrade();
content.connect_script_message_received(Some("hv"), move |_, value| {
// ...
if let Some(dlg) = dlg_weak.upgrade() {
dlg.close();
}
});
```
2. **Double-Serialization Tolerance:** The JavaScript message listener forwards event data as a JSON string to the native handler. Since the underlying page may post either JS objects or pre-serialized JSON strings, the Rust side performs dual-phase parsing:
```rust
let mut value = serde_json::from_str(raw).ok()?;
if let Some(inner) = value.as_str() {
if let Ok(parsed) = serde_json::from_str(inner) {
value = parsed;
}
}
```
This ensures compatibility with all versions of Proton's client verification scripts.
3. **SRP Handshake Reset:** Because a gated login burns the SRP handshake on the API side, the client cannot simply resume the previous request. Instead, `auth::login_interactive` restarts the SRP process from scratch with the verification credentials attached, keeping the complex handshake details isolated from the front-end.
4. **CLI Fallback:** Since the CLI has no native web browser engine, hitting the CAPTCHA gate fails immediately with a user-friendly message directing the user to sign in once via the GUI (`pdfs-app`) to persist the authenticated session keys to the system keyring.
+1140
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -9,13 +9,13 @@
# Build + install: cd packaging && makepkg -fi # Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux pkgname=proton-drive-linux
pkgver=0.5.0 pkgver=0.6.1
pkgrel=1 pkgrel=1
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray" pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
arch=('x86_64') arch=('x86_64')
url="https://github.com/narl/proton-drive-linux" url="https://github.com/narl/proton-drive-linux"
license=('MIT') license=('MIT')
depends=('fuse3' 'gtk4' 'libadwaita') depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0')
makedepends=('cargo' 'git') makedepends=('cargo' 'git')
provides=('pdfs') provides=('pdfs')
# Files-on-demand mount + keyring need these at runtime. # Files-on-demand mount + keyring need these at runtime.
+5 -1
View File
@@ -7,8 +7,10 @@
# Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice. # Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice.
%global _lto_cflags %{nil} %global _lto_cflags %{nil}
%{!?version: %global version 0.4.0}
Name: proton-drive-linux Name: proton-drive-linux
Version: 0.4.0 Version: %{version}
Release: 1%{?dist} Release: 1%{?dist}
Summary: Proton Drive client for Linux (FUSE, CLI, GTK4 app + tray) Summary: Proton Drive client for Linux (FUSE, CLI, GTK4 app + tray)
License: MIT License: MIT
@@ -24,11 +26,13 @@ BuildRequires: libadwaita-devel
BuildRequires: libsecret-devel BuildRequires: libsecret-devel
BuildRequires: dbus-devel BuildRequires: dbus-devel
BuildRequires: glib2-devel BuildRequires: glib2-devel
BuildRequires: webkitgtk6.0-devel
Requires: fuse3 Requires: fuse3
Requires: gtk4 Requires: gtk4
Requires: libadwaita Requires: libadwaita
Requires: libsecret Requires: libsecret
Requires: webkitgtk6.0
Requires: xdg-utils Requires: xdg-utils
# DE-specific; do not Require a single desktop environment. # DE-specific; do not Require a single desktop environment.