mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 08:56:59 +01:00
Compare commits
22
Commits
feature/scaff
...
v0.6.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
661ae3cf7a | ||
|
|
f803bf3cd7 | ||
|
|
084740ad95 | ||
|
|
65eed0cc72 | ||
|
|
1e0e284d5f | ||
|
|
f0fbd0775c | ||
|
|
d25a573e8f | ||
|
|
eafe1307b8 | ||
|
|
056ae0463f | ||
|
|
b2b0fe7b99 | ||
|
|
d13042b550 | ||
|
|
cb67d97b0f | ||
|
|
0323fdc44d | ||
|
|
2b2a49c679 | ||
|
|
ed129f7c71 | ||
|
|
b39d09fb9d | ||
|
|
c8f57ed5c9 | ||
|
|
b90b7e4b9c | ||
|
|
cd437dff9c | ||
|
|
681db981f5 | ||
|
|
45bd48fa5f | ||
|
|
fb39594453 |
No files matched your search
@@ -26,9 +26,11 @@ 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 \
|
||||||
|
rpm
|
||||||
|
|
||||||
- name: Set up Rust
|
- name: Set up Rust
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
@@ -97,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:
|
||||||
@@ -109,6 +111,28 @@ jobs:
|
|||||||
# Build Debian package
|
# Build Debian package
|
||||||
dpkg-deb --build deb proton-drive-linux_${{ env.VERSION }}_amd64.deb
|
dpkg-deb --build deb proton-drive-linux_${{ env.VERSION }}_amd64.deb
|
||||||
|
|
||||||
|
- name: Package Fedora package (.rpm)
|
||||||
|
run: |
|
||||||
|
# Sanitize version (replace hyphens with dots for RPM compatibility)
|
||||||
|
RPM_VERSION=$(echo "${{ env.VERSION }}" | tr '-' '.')
|
||||||
|
|
||||||
|
# Create RPM build directories
|
||||||
|
mkdir -p rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS,BUILDROOT,db}
|
||||||
|
|
||||||
|
# Build RPM package
|
||||||
|
rpmbuild -bb \
|
||||||
|
--nodeps \
|
||||||
|
--define "_topdir $PWD/rpmbuild" \
|
||||||
|
--define "_sourcedir $PWD" \
|
||||||
|
--define "git_dir $PWD" \
|
||||||
|
--define "version ${RPM_VERSION}" \
|
||||||
|
--define "_dbpath $PWD/rpmbuild/db" \
|
||||||
|
packaging/proton-drive-linux.spec
|
||||||
|
|
||||||
|
# Copy built RPM to root directory
|
||||||
|
cp rpmbuild/RPMS/x86_64/*.rpm .
|
||||||
|
|
||||||
|
|
||||||
- name: Upload Workflow Artifacts
|
- name: Upload Workflow Artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -116,6 +140,7 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
*.deb
|
*.deb
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
*.rpm
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
@@ -125,6 +150,7 @@ jobs:
|
|||||||
files: |
|
files: |
|
||||||
*.deb
|
*.deb
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
*.rpm
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
*.pdb
|
*.pdb
|
||||||
.env
|
.env
|
||||||
|
/rpmbuild
|
||||||
Generated
+353
-245
File diff suppressed because it is too large.
Load diff
+3
-3
@@ -3,15 +3,15 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.5.0"
|
version = "0.6.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
rust-version = "1.96"
|
rust-version = "1.96"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.1", path = "../proton-sdk-rs/crates/proton-sdk" }
|
proton-sdk = { version = "0.1" }
|
||||||
proton-drive-rs = { version = "0.1", path = "../proton-sdk-rs/crates/proton-drive-rs" }
|
proton-drive-rs = { version = "0.1" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
|
|||||||
@@ -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,13 +55,30 @@ 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+)
|
||||||
|
```bash
|
||||||
|
sudo dnf install -y \
|
||||||
|
pkgconf-pkg-config fuse3-devel gtk4-devel libadwaita-devel \
|
||||||
|
webkitgtk6.0-devel libsecret-devel dbus-devel glib2-devel cargo rust
|
||||||
|
```
|
||||||
|
|
||||||
|
Runtime extras (pick your desktop):
|
||||||
|
```bash
|
||||||
|
# GNOME — keyring + tray (AppIndicator)
|
||||||
|
sudo dnf install -y gnome-keyring gnome-shell-extension-appindicator xdg-utils
|
||||||
|
|
||||||
|
# KDE Plasma — KWallet (Secret Service); tray works via built-in SNI
|
||||||
|
sudo dnf install -y kwallet xdg-utils
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -101,6 +119,20 @@ A local `PKGBUILD` is available under the `packaging/` directory. You can build
|
|||||||
cd packaging && makepkg -fi
|
cd packaging && makepkg -fi
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 3. Fedora (local RPM)
|
||||||
|
A local `.spec` is available under `packaging/`. From the repository root:
|
||||||
|
```bash
|
||||||
|
sudo dnf install -y rpm-build
|
||||||
|
rpmbuild -bb packaging/proton-drive-linux.spec \
|
||||||
|
--define "git_dir $PWD" \
|
||||||
|
--define "_rpmdir $PWD/packaging/out" \
|
||||||
|
--define "_builddir $PWD/packaging/build" \
|
||||||
|
--define "_sourcedir $PWD" \
|
||||||
|
--define "_specdir $PWD/packaging" \
|
||||||
|
--define "_srcrpmdir $PWD/packaging/out"
|
||||||
|
sudo dnf install packaging/out/x86_64/proton-drive-linux-*.rpm
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Automated Releases (CI/CD)
|
## Automated Releases (CI/CD)
|
||||||
|
|||||||
@@ -817,8 +817,19 @@ fn cmd_login(username: Option<String>) -> Result<()> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let rt = tokio::runtime::Runtime::new()?;
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
|
// Solving a CAPTCHA needs a browser engine, which the CLI has no business
|
||||||
|
// carrying. Point at the app that does rather than failing with the raw API
|
||||||
|
// message, which reads as "your login is broken".
|
||||||
rt.block_on(auth::login(&username, &password, get_totp))
|
rt.block_on(auth::login(&username, &password, get_totp))
|
||||||
.context("login failed")?;
|
.map_err(|e| match e {
|
||||||
|
pdfs_core::Error::HumanVerificationRequired(_) => anyhow::anyhow!(
|
||||||
|
"Proton is asking for a CAPTCHA before it will accept this sign-in.\n\
|
||||||
|
Run `pdfs-app` and sign in there — it can show the verification page.\n\
|
||||||
|
(This is usually triggered by a VPN or an unfamiliar IP; signing in from \
|
||||||
|
your usual network often avoids it.)"
|
||||||
|
),
|
||||||
|
other => anyhow::Error::new(other).context("login failed"),
|
||||||
|
})?;
|
||||||
|
|
||||||
println!("Logged in as {username}. Session stored in the system keyring.");
|
println!("Logged in as {username}. Session stored in the system keyring.");
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
use keyring::Entry;
|
use keyring::Entry;
|
||||||
use proton_drive_rs::{KeySalt, ProtonDriveClient};
|
use proton_drive_rs::{KeySalt, ProtonDriveClient};
|
||||||
|
use proton_sdk::api::{HumanVerification, HumanVerificationCredential};
|
||||||
use proton_sdk::config::ProtonClientConfiguration;
|
use proton_sdk::config::ProtonClientConfiguration;
|
||||||
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -84,14 +85,68 @@ fn keyring_entry() -> Result<Entry> {
|
|||||||
/// Run an interactive SRP + (optional) 2FA login and persist the session.
|
/// Run an interactive SRP + (optional) 2FA login and persist the session.
|
||||||
///
|
///
|
||||||
/// `get_totp` is only invoked when the account requires a second factor, so
|
/// `get_totp` is only invoked when the account requires a second factor, so
|
||||||
/// callers can defer prompting until it is actually needed.
|
/// callers can defer prompting until it is actually needed. It may be called
|
||||||
|
/// more than once — [`login_interactive`] re-runs the whole login after human
|
||||||
|
/// verification, and a 2FA account needs a fresh code on that second attempt.
|
||||||
|
///
|
||||||
|
/// A gated login fails with [`Error::HumanVerificationRequired`]; a front-end
|
||||||
|
/// that can present the challenge should use [`login_interactive`] instead.
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
username: &str,
|
username: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
get_totp: impl FnOnce() -> Result<String>,
|
get_totp: impl Fn() -> Result<String>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut session =
|
login_verified(username, password, None, get_totp).await
|
||||||
ProtonApiSession::begin(client_config(), username, password.as_bytes()).await?;
|
}
|
||||||
|
|
||||||
|
/// [`login`], able to answer a human-verification gate rather than fail on it.
|
||||||
|
///
|
||||||
|
/// `get_hv` is invoked only when the API actually gates the login, mirroring how
|
||||||
|
/// `get_totp` is only invoked for accounts with a second factor: neither costs a
|
||||||
|
/// prompt the user did not need. It receives the challenge and returns the token
|
||||||
|
/// the user earned by solving it.
|
||||||
|
///
|
||||||
|
/// The retry lives here rather than in the front-ends because the recovery is
|
||||||
|
/// not a UI concern: the gated attempt burned its SRP handshake, so the login
|
||||||
|
/// has to start over with the credential attached, and every front-end would
|
||||||
|
/// otherwise have to know that. Gating the *retry* is not handled — a second
|
||||||
|
/// challenge in a row means the token was rejected, and looping on it would trap
|
||||||
|
/// the user in a CAPTCHA that never clears.
|
||||||
|
pub async fn login_interactive(
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
get_totp: impl Fn() -> Result<String>,
|
||||||
|
get_hv: impl FnOnce(HumanVerification) -> Result<HumanVerificationCredential>,
|
||||||
|
) -> Result<()> {
|
||||||
|
match login_verified(username, password, None, &get_totp).await {
|
||||||
|
Err(Error::HumanVerificationRequired(hv)) => {
|
||||||
|
let credential = get_hv(*hv)?;
|
||||||
|
login_verified(username, password, Some(&credential), &get_totp).await
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`login`], replaying a human-verification token the user has already earned.
|
||||||
|
///
|
||||||
|
/// A gated login fails with [`Error::HumanVerificationRequired`] carrying the
|
||||||
|
/// challenge. The front-end presents it, collects the solved token, and calls
|
||||||
|
/// this. The login starts over from the SRP handshake rather than resuming: the
|
||||||
|
/// gated attempt never got a session, and its `srp_session` is spent.
|
||||||
|
pub async fn login_verified(
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
verification: Option<&HumanVerificationCredential>,
|
||||||
|
get_totp: impl Fn() -> Result<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut session = ProtonApiSession::begin_verified(
|
||||||
|
client_config(),
|
||||||
|
username,
|
||||||
|
password.as_bytes(),
|
||||||
|
verification,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(classify_verification_gate)?;
|
||||||
|
|
||||||
if session.is_waiting_for_second_factor() {
|
if session.is_waiting_for_second_factor() {
|
||||||
let code = get_totp()?;
|
let code = get_totp()?;
|
||||||
@@ -110,6 +165,23 @@ pub async fn login(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lift a human-verification gate out of the generic API error into the typed
|
||||||
|
/// variant a front-end can act on.
|
||||||
|
///
|
||||||
|
/// Only a gate that names a solvable method is converted. A `9001` with no
|
||||||
|
/// `Details`, or one offering only `email`/`sms`, stays a plain API error:
|
||||||
|
/// promoting it would send the UI to open a verification page it cannot
|
||||||
|
/// complete, which reads to the user as a hang rather than a refusal.
|
||||||
|
fn classify_verification_gate(e: proton_sdk::error::ProtonError) -> Error {
|
||||||
|
let proton_sdk::error::ProtonError::Api(api) = &e else {
|
||||||
|
return e.into();
|
||||||
|
};
|
||||||
|
match api.human_verification() {
|
||||||
|
Some(hv) if hv.supports_captcha() => Error::HumanVerificationRequired(Box::new(hv)),
|
||||||
|
_ => e.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Persist the session's current tokens, mailbox password and key salts.
|
/// Persist the session's current tokens, mailbox password and key salts.
|
||||||
pub async fn save(
|
pub async fn save(
|
||||||
session: &ProtonApiSession,
|
session: &ProtonApiSession,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
";
|
||||||
@@ -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",
|
||||||
)?;
|
)?;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Crate-wide error type.
|
//! Crate-wide error type.
|
||||||
|
|
||||||
|
use proton_sdk::api::HumanVerification;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::control::ErrorKind;
|
use crate::control::ErrorKind;
|
||||||
@@ -31,6 +32,15 @@ pub enum Error {
|
|||||||
#[error("saved session can no longer unlock your keys — run `pdfs login` again")]
|
#[error("saved session can no longer unlock your keys — run `pdfs login` again")]
|
||||||
ReloginRequired,
|
ReloginRequired,
|
||||||
|
|
||||||
|
/// The login was gated behind human verification. Not a failure the user can
|
||||||
|
/// fix by retrying: the challenge has to be solved and the login restarted
|
||||||
|
/// with the resulting token (see [`auth::login_verified`](crate::auth::login_verified)).
|
||||||
|
///
|
||||||
|
/// Carries the challenge rather than just a message so a front-end can
|
||||||
|
/// present it; a front-end that cannot (no webview) still has prose to show.
|
||||||
|
#[error("sign-in needs human verification — complete the CAPTCHA to continue")]
|
||||||
|
HumanVerificationRequired(Box<HumanVerification>),
|
||||||
|
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Other(String),
|
Other(String),
|
||||||
}
|
}
|
||||||
@@ -154,7 +164,9 @@ impl From<serde_json::Error> for CoreError {
|
|||||||
impl From<Error> for CoreError {
|
impl From<Error> for CoreError {
|
||||||
fn from(e: Error) -> Self {
|
fn from(e: Error) -> Self {
|
||||||
let kind = match &e {
|
let kind = match &e {
|
||||||
Error::NotLoggedIn | Error::ReloginRequired => ErrorKind::Denied,
|
Error::NotLoggedIn | Error::ReloginRequired | Error::HumanVerificationRequired(_) => {
|
||||||
|
ErrorKind::Denied
|
||||||
|
}
|
||||||
Error::Proton(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
|
Error::Proton(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
|
||||||
// A `ProtonError` that never reached the API is a transport failure.
|
// A `ProtonError` that never reached the API is a transport failure.
|
||||||
Error::Proton(_) => ErrorKind::Offline,
|
Error::Proton(_) => ErrorKind::Offline,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+1022
-30
File diff suppressed because it is too large.
Load diff
@@ -30,3 +30,4 @@ gtk4 = { version = "0.9", features = ["v4_12"] }
|
|||||||
adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] }
|
adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] }
|
||||||
async-channel = "2"
|
async-channel = "2"
|
||||||
exif = { version = "0.6", package = "kamadak-exif" }
|
exif = { version = "0.6", package = "kamadak-exif" }
|
||||||
|
webkit6 = "0.4"
|
||||||
@@ -11,6 +11,7 @@ use pages::shared::*;
|
|||||||
use pages::shared_by_me::*;
|
use pages::shared_by_me::*;
|
||||||
use pages::status::*;
|
use pages::status::*;
|
||||||
use pages::trash::*;
|
use pages::trash::*;
|
||||||
|
use pages::verify::*;
|
||||||
use widgets::details::*;
|
use widgets::details::*;
|
||||||
use widgets::share_dialog::*;
|
use widgets::share_dialog::*;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::*;
|
use crate::*;
|
||||||
|
use pdfs_core::proton_sdk::api::HumanVerificationCredential;
|
||||||
|
|
||||||
pub(crate) struct LoginState {
|
pub(crate) struct LoginState {
|
||||||
// Login page.
|
// Login page.
|
||||||
@@ -104,18 +105,35 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
|||||||
|
|
||||||
ui.login.login_button.set_sensitive(false);
|
ui.login.login_button.set_sensitive(false);
|
||||||
ui.login.login_status.set_text("Signing in…");
|
ui.login.login_status.set_text("Signing in…");
|
||||||
let (rx, totp_req_rx) = spawn_login(username, password);
|
let (rx, totp_req_rx, hv_req_rx) = spawn_login(username, password);
|
||||||
|
|
||||||
// Surface the 2FA dialog only if the SDK actually asks for a code (i.e.
|
// Surface the 2FA dialog only if the SDK actually asks for a code (i.e.
|
||||||
// the account has 2FA enabled). The worker blocks until the dialog feeds
|
// the account has 2FA enabled). The worker blocks until the dialog feeds
|
||||||
// back a code (or is cancelled, dropping the sender).
|
// back a code (or is cancelled, dropping the sender).
|
||||||
|
//
|
||||||
|
// Loops rather than awaiting once: a login gated behind a CAPTCHA is
|
||||||
|
// restarted after verification, so a 2FA account is asked for a second,
|
||||||
|
// unexpired code on the retry.
|
||||||
let ui_2fa = ui.clone();
|
let ui_2fa = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
if let Ok(code_tx) = totp_req_rx.recv().await {
|
while let Ok(code_tx) = totp_req_rx.recv().await {
|
||||||
prompt_2fa(&ui_2fa, code_tx);
|
prompt_2fa(&ui_2fa, code_tx);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Same shape for human verification: only fires when the API gates the
|
||||||
|
// sign-in, and the worker is blocked on the token until it does.
|
||||||
|
let ui_hv = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
if let Ok((url, token_tx)) = hv_req_rx.recv().await {
|
||||||
|
ui_hv
|
||||||
|
.login
|
||||||
|
.login_status
|
||||||
|
.set_text("Complete the verification to continue…");
|
||||||
|
prompt_human_verification(&ui_hv, &url, token_tx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let result = rx
|
let result = rx
|
||||||
@@ -155,9 +173,13 @@ pub(crate) fn spawn_login(
|
|||||||
) -> (
|
) -> (
|
||||||
async_channel::Receiver<Result<(), String>>,
|
async_channel::Receiver<Result<(), String>>,
|
||||||
async_channel::Receiver<std::sync::mpsc::Sender<String>>,
|
async_channel::Receiver<std::sync::mpsc::Sender<String>>,
|
||||||
|
async_channel::Receiver<(String, std::sync::mpsc::Sender<String>)>,
|
||||||
) {
|
) {
|
||||||
let (tx, rx) = async_channel::bounded(1);
|
let (tx, rx) = async_channel::bounded(1);
|
||||||
let (totp_req_tx, totp_req_rx) = async_channel::bounded(1);
|
// Two, not one: a CAPTCHA-gated login restarts, so a 2FA account is asked
|
||||||
|
// for a code on each attempt and a single slot would deadlock the worker.
|
||||||
|
let (totp_req_tx, totp_req_rx) = async_channel::bounded(2);
|
||||||
|
let (hv_req_tx, hv_req_rx) = async_channel::bounded(1);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
@@ -170,10 +192,13 @@ pub(crate) fn spawn_login(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let result = rt.block_on(async move {
|
let result = rt.block_on(async move {
|
||||||
auth::login(&username, &password, || {
|
auth::login_interactive(
|
||||||
// 2FA required: hand a one-shot sender to the UI and block until
|
&username,
|
||||||
// the dialog supplies the code. A dropped sender (cancelled
|
&password,
|
||||||
// dialog) surfaces as a cancelled login.
|
|| {
|
||||||
|
// 2FA required: hand a one-shot sender to the UI and block
|
||||||
|
// until the dialog supplies the code. A dropped sender
|
||||||
|
// (cancelled dialog) surfaces as a cancelled login.
|
||||||
let (code_tx, code_rx) = std::sync::mpsc::channel::<String>();
|
let (code_tx, code_rx) = std::sync::mpsc::channel::<String>();
|
||||||
totp_req_tx
|
totp_req_tx
|
||||||
.send_blocking(code_tx)
|
.send_blocking(code_tx)
|
||||||
@@ -181,13 +206,28 @@ pub(crate) fn spawn_login(
|
|||||||
code_rx
|
code_rx
|
||||||
.recv()
|
.recv()
|
||||||
.map_err(|_| pdfs_core::Error::Other("two-factor entry cancelled".into()))
|
.map_err(|_| pdfs_core::Error::Other("two-factor entry cancelled".into()))
|
||||||
})
|
},
|
||||||
|
|hv| {
|
||||||
|
// Gated: hand the UI the page to show, block on the token the
|
||||||
|
// user earns by solving it.
|
||||||
|
let (token_tx, token_rx) = std::sync::mpsc::channel::<String>();
|
||||||
|
hv_req_tx
|
||||||
|
.send_blocking((hv.verification_url(), token_tx))
|
||||||
|
.map_err(|_| {
|
||||||
|
pdfs_core::Error::Other("verification prompt closed".into())
|
||||||
|
})?;
|
||||||
|
let token = token_rx
|
||||||
|
.recv()
|
||||||
|
.map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))?;
|
||||||
|
Ok(HumanVerificationCredential::captcha(token))
|
||||||
|
},
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
});
|
});
|
||||||
let _ = tx.send_blocking(result);
|
let _ = tx.send_blocking(result);
|
||||||
});
|
});
|
||||||
(rx, totp_req_rx)
|
(rx, totp_req_rx, hv_req_rx)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show the lazy two-factor dialog and feed the entered code back to the waiting
|
/// Show the lazy two-factor dialog and feed the entered code back to the waiting
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ pub(crate) mod shared;
|
|||||||
pub(crate) mod shared_by_me;
|
pub(crate) mod shared_by_me;
|
||||||
pub(crate) mod status;
|
pub(crate) mod status;
|
||||||
pub(crate) mod trash;
|
pub(crate) mod trash;
|
||||||
|
pub(crate) mod verify;
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
//! The human-verification (CAPTCHA) dialog.
|
||||||
|
//!
|
||||||
|
//! Proton gates logins it does not recognise — a new IP, a VPN exit — behind a
|
||||||
|
//! CAPTCHA. The API answers the login with a challenge instead of a session, and
|
||||||
|
//! expects the client to present Proton's hosted verification page, collect the
|
||||||
|
//! token the user earns by solving it, and retry the login carrying that token.
|
||||||
|
//!
|
||||||
|
//! The page is hosted in a `WebKitWebView` rather than handed to the system
|
||||||
|
//! browser because it reports completion by posting a message to its host, not
|
||||||
|
//! by redirecting: a browser has nowhere to post it back to.
|
||||||
|
|
||||||
|
use crate::*;
|
||||||
|
use webkit6::prelude::*;
|
||||||
|
|
||||||
|
/// Bridge the verification page's `postMessage` to a handler this side can read.
|
||||||
|
///
|
||||||
|
/// The page targets its embedding host (`window.parent`), which in a webview is
|
||||||
|
/// the page itself — so nothing arrives unless the message is forwarded
|
||||||
|
/// explicitly. Injecting at document start guarantees the listener is installed
|
||||||
|
/// before the page can post anything.
|
||||||
|
const BRIDGE: &str = r#"
|
||||||
|
window.addEventListener('message', function (event) {
|
||||||
|
try {
|
||||||
|
window.webkit.messageHandlers.hv.postMessage(JSON.stringify(event.data));
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Present the challenge and hand the solved token back through `token_tx`.
|
||||||
|
///
|
||||||
|
/// Closing the dialog without solving drops the sender, which the waiting login
|
||||||
|
/// worker reads as a cancelled sign-in — the same contract as the 2FA prompt.
|
||||||
|
pub(crate) fn prompt_human_verification(
|
||||||
|
ui: &Rc<Ui>,
|
||||||
|
url: &str,
|
||||||
|
token_tx: std::sync::mpsc::Sender<String>,
|
||||||
|
) {
|
||||||
|
let content = webkit6::UserContentManager::new();
|
||||||
|
content.add_script(&webkit6::UserScript::new(
|
||||||
|
BRIDGE,
|
||||||
|
webkit6::UserContentInjectedFrames::AllFrames,
|
||||||
|
webkit6::UserScriptInjectionTime::Start,
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
));
|
||||||
|
// Registration is what makes `messageHandlers.hv` exist in the page; without
|
||||||
|
// it the bridge above throws on every message.
|
||||||
|
content.register_script_message_handler("hv", None);
|
||||||
|
|
||||||
|
let webview = webkit6::WebView::builder()
|
||||||
|
.user_content_manager(&content)
|
||||||
|
.vexpand(true)
|
||||||
|
.hexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let dialog = adw::Dialog::builder()
|
||||||
|
.title("Verification")
|
||||||
|
.content_width(420)
|
||||||
|
.content_height(560)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let header = adw::HeaderBar::new();
|
||||||
|
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||||
|
body.append(&header);
|
||||||
|
body.append(&webview);
|
||||||
|
dialog.set_child(Some(&body));
|
||||||
|
|
||||||
|
// Taken on the first success *or* on close, so the token is sent at most
|
||||||
|
// once and a cancelled dialog always drops the sender.
|
||||||
|
let token_tx = Rc::new(RefCell::new(Some(token_tx)));
|
||||||
|
|
||||||
|
let tx = token_tx.clone();
|
||||||
|
let dlg_weak = dialog.downgrade();
|
||||||
|
content.connect_script_message_received(Some("hv"), move |_, value| {
|
||||||
|
let Some(token) = extract_token(&value.to_str()) else {
|
||||||
|
// Every `postMessage` on the page reaches here, most of them the
|
||||||
|
// verification app's own chatter. Anything that is not a completion
|
||||||
|
// is simply not ours.
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(tx) = tx.borrow_mut().take() {
|
||||||
|
let _ = tx.send(token);
|
||||||
|
}
|
||||||
|
if let Some(dlg) = dlg_weak.upgrade() {
|
||||||
|
dlg.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Covers the close button, Escape, and the user giving up.
|
||||||
|
dialog.connect_closed(move |_| {
|
||||||
|
token_tx.borrow_mut().take();
|
||||||
|
});
|
||||||
|
|
||||||
|
webview.load_uri(url);
|
||||||
|
|
||||||
|
let parent = ui.login.login_button.root().and_downcast::<gtk4::Window>();
|
||||||
|
// Returns immediately; the login worker stays blocked on the channel until
|
||||||
|
// the handler above sends, or the dialog closes and drops the sender.
|
||||||
|
dialog.present(parent.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull the verification token out of a message posted by the verification page.
|
||||||
|
///
|
||||||
|
/// Returns `None` for anything that is not a completion message. The page posts
|
||||||
|
/// a good deal besides — resize requests, readiness pings — and treating an
|
||||||
|
/// unrecognised shape as success would hand the API an empty token and fail the
|
||||||
|
/// login with a confusing error.
|
||||||
|
fn extract_token(raw: &str) -> Option<String> {
|
||||||
|
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
|
||||||
|
// `type`; both spellings of the success type have shipped.
|
||||||
|
let kind = value.get("type")?.as_str()?;
|
||||||
|
if !matches!(
|
||||||
|
kind,
|
||||||
|
"HUMAN_VERIFICATION_SUCCESS" | "human_verification_success"
|
||||||
|
) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let payload = value.get("payload")?;
|
||||||
|
let token = payload.get("token")?.as_str()?;
|
||||||
|
if token.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(token.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::extract_token;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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
|
||||||
|
/// mistaken for one, or the login retries with a garbage token.
|
||||||
|
#[test]
|
||||||
|
fn unrelated_messages_are_ignored() {
|
||||||
|
for raw in [
|
||||||
|
r#"{"type":"resize","payload":{"height":400}}"#,
|
||||||
|
r#"{"type":"HUMAN_VERIFICATION_SUCCESS"}"#,
|
||||||
|
r#"{"payload":{"token":"tok"}}"#,
|
||||||
|
r#"{"type":"HUMAN_VERIFICATION_SUCCESS","payload":{"token":""}}"#,
|
||||||
|
"not json at all",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
assert!(extract_token(raw).is_none(), "accepted: {raw}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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| {
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large.
Load diff
+2
-2
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Local in-tree RPM (mirrors packaging/PKGBUILD).
|
||||||
|
# From the repository root:
|
||||||
|
# rpmbuild -bb packaging/proton-drive-linux.spec --define "git_dir $PWD" ...
|
||||||
|
# %build needs network so cargo can fetch crates.io (same trade-off as the Arch PKGBUILD).
|
||||||
|
|
||||||
|
%global debug_package %{nil}
|
||||||
|
# Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice.
|
||||||
|
%global _lto_cflags %{nil}
|
||||||
|
|
||||||
|
%{!?version: %global version 0.4.0}
|
||||||
|
|
||||||
|
Name: proton-drive-linux
|
||||||
|
Version: %{version}
|
||||||
|
Release: 1%{?dist}
|
||||||
|
Summary: Proton Drive client for Linux (FUSE, CLI, GTK4 app + tray)
|
||||||
|
License: MIT
|
||||||
|
URL: https://github.com/narl/proton-drive-linux
|
||||||
|
ExclusiveArch: x86_64
|
||||||
|
|
||||||
|
BuildRequires: cargo
|
||||||
|
BuildRequires: rust
|
||||||
|
BuildRequires: pkgconf-pkg-config
|
||||||
|
BuildRequires: fuse3-devel
|
||||||
|
BuildRequires: gtk4-devel
|
||||||
|
BuildRequires: libadwaita-devel
|
||||||
|
BuildRequires: libsecret-devel
|
||||||
|
BuildRequires: dbus-devel
|
||||||
|
BuildRequires: glib2-devel
|
||||||
|
BuildRequires: webkitgtk6.0-devel
|
||||||
|
|
||||||
|
Requires: fuse3
|
||||||
|
Requires: gtk4
|
||||||
|
Requires: libadwaita
|
||||||
|
Requires: libsecret
|
||||||
|
Requires: webkitgtk6.0
|
||||||
|
Requires: xdg-utils
|
||||||
|
|
||||||
|
# DE-specific; do not Require a single desktop environment.
|
||||||
|
Recommends: gnome-keyring
|
||||||
|
Recommends: gnome-shell-extension-appindicator
|
||||||
|
Recommends: kwallet
|
||||||
|
|
||||||
|
Provides: pdfs = %{version}-%{release}
|
||||||
|
|
||||||
|
%description
|
||||||
|
Unofficial Proton Drive client featuring a FUSE files-on-demand mount,
|
||||||
|
CLI, GTK4/Libadwaita GUI, system tray, and search launcher.
|
||||||
|
|
||||||
|
%prep
|
||||||
|
# In-tree build: no Source tarball. Pass --define "git_dir /path/to/checkout".
|
||||||
|
test -n "%{?git_dir}" || (echo 'Pass --define "git_dir $PWD" from the repo root' >&2; exit 1)
|
||||||
|
test -f %{git_dir}/Cargo.toml
|
||||||
|
cp -a %{git_dir}/LICENSE .
|
||||||
|
%build
|
||||||
|
cd %{git_dir}
|
||||||
|
cargo build --release --locked \
|
||||||
|
--bin pdfs \
|
||||||
|
--bin pdfs-tray \
|
||||||
|
--bin pdfs-app \
|
||||||
|
--bin pdfs-prompt
|
||||||
|
|
||||||
|
%install
|
||||||
|
rel=%{git_dir}/target/release
|
||||||
|
install -D -m0755 "$rel/pdfs" %{buildroot}%{_bindir}/pdfs
|
||||||
|
install -D -m0755 "$rel/pdfs-tray" %{buildroot}%{_bindir}/pdfs-tray
|
||||||
|
install -D -m0755 "$rel/pdfs-app" %{buildroot}%{_bindir}/pdfs-app
|
||||||
|
install -D -m0755 "$rel/pdfs-prompt" %{buildroot}%{_bindir}/pdfs-prompt
|
||||||
|
|
||||||
|
install -D -m0644 %{git_dir}/packaging/io.narl.proton-drive-linux.desktop \
|
||||||
|
%{buildroot}%{_datadir}/applications/io.narl.proton-drive-linux.desktop
|
||||||
|
install -D -m0644 %{git_dir}/packaging/io.narl.proton-drive-linux-tray.desktop \
|
||||||
|
%{buildroot}%{_sysconfdir}/xdg/autostart/io.narl.proton-drive-linux-tray.desktop
|
||||||
|
install -D -m0644 %{git_dir}/packaging/io.narl.proton-drive-linux.svg \
|
||||||
|
%{buildroot}%{_datadir}/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg
|
||||||
|
install -D -m0644 %{git_dir}/packaging/proton-drive.service \
|
||||||
|
%{buildroot}/usr/lib/systemd/user/proton-drive.service
|
||||||
|
|
||||||
|
%files
|
||||||
|
%license LICENSE
|
||||||
|
%{_bindir}/pdfs
|
||||||
|
%{_bindir}/pdfs-tray
|
||||||
|
%{_bindir}/pdfs-app
|
||||||
|
%{_bindir}/pdfs-prompt
|
||||||
|
%{_datadir}/applications/io.narl.proton-drive-linux.desktop
|
||||||
|
%{_sysconfdir}/xdg/autostart/io.narl.proton-drive-linux-tray.desktop
|
||||||
|
%{_datadir}/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg
|
||||||
|
/usr/lib/systemd/user/proton-drive.service
|
||||||
|
|
||||||
|
%changelog
|
||||||
|
* Sun Jul 19 2026 Local Packager - 0.4.0-1
|
||||||
|
- Initial Fedora local package (in-tree cargo build).
|
||||||
Reference in new issue
Block a user