Compare commits

..
Author SHA1 Message Date
nvrl 4c498d559c Merge pull request #32 from narrrl/fix/captcha-in-browser
fix(gui,cli,core): offer the CAPTCHA in the browser when the web view…
2026-10-09 03:04:10 +02:00
nvrl 7fd3439b60 Merge remote-tracking branch 'origin/main' into pr32-merge
# Conflicts:
#	docs/CHANGELOG.md
#	po/de.po
#	po/en_GB.po
#	po/es.po
#	po/fr.po
#	po/it.po
#	po/nl.po
#	po/pdfs.pot
#	po/pl.po
#	po/pt_BR.po
2026-10-09 03:03:31 +02:00
nvrl c4847c4d52 chore(release): 3.2.0 2026-10-09 02:56:26 +02:00
nvrl ad49e32de9 fix(gui): context menus size to fit instead of scrolling
A popover is sized and placed again when its parent's layout manager
allocates it. Context menus were parented to the outermost list view,
and the keyboard menu to the ScrolledWindow; neither has a layout
manager, so a menu kept the size it was first given and often opened
too short, with a scrollbar.

The host is now the first widget from the list view up that has a
layout manager, which still keeps menus out of list items and the theme
rules that style their insides.
2026-10-09 02:44:00 +02:00
nvrl 19854b2d76 fix(gui): tidy the Share dialog
Send Invitations and Create Public Link sat alone in boxed-list rows,
which read as broken settings. They now sit under their section at its
right edge. The role picker moves into the address row it applies to,
people get avatars, a member needs no subtitle, and invitations say they
are pending. The People and Public link sections show a loading row
until the share is read, and one line instead of nothing when it can't
be. The page is an AdwPreferencesPage, for the standard spacing.

Translated in every catalog.
2026-10-09 02:41:07 +02:00
nvrl 085335ca14 feat(gui): one main menu, on the sidebar's account row
The sidebar header had a hamburger beside the brand, and the account row
in the footer had its own ⋮ menu; both offered Preferences. The header
button sat next to the page's own tools and read as one of them.

The account row is now the main menu's button, as in Proton's own apps:
Preferences, Keyboard Shortcuts and About, then Sign Out. The whole row
opens it, upwards, and F10 still does as the primary menu.
2026-10-09 02:37:25 +02:00
nvrl 9fb7e84d41 fix(fuse,gui): a stopped share leaves Shared by me at once (B199)
Stop Sharing removes the link, invitations and members but cannot delete
the share itself, and Drive keeps listing the empty share until its
backend removes it, minutes later.

The daemon now leaves a share with no member, invitation or link out of
the listing (unless a lookup failed, so a live share is never hidden),
and the app drops stopped items from the page as soon as the requests
succeed instead of waiting for the slow reload.
2026-10-09 02:34:03 +02:00
nvrl 7ebe8e5210 fix(fuse,core,gui): routine mirror scans no longer show as syncing
Every mirror pass, the two-minute poll included, reported a "Checking
{folder}" job, and the tray took any job for syncing, so its icon spun
every two minutes over folders with nothing to do.

JobItem gains a background flag (absent from older daemons, so false)
for housekeeping that changes nothing in the user's files: a pass still
scanning, the home index rebuild and the cache integrity check. The tray
keeps them in its menu line but only turns busy for transfers or a job
that is not background; a pass that queues work becomes a Syncing job
and still spins the icon.

The watcher also reconciles only the folder an event lies in, instead of
walking every mirror folder over one edit.
2026-10-09 02:27:50 +02:00
nvrl 922399b9a2 fix(gui): even breadcrumb spacing, no invented hyphens in tile names
The current folder in the breadcrumb trail was a bare label, so the "›"
before it hugged it while the ones between buttons sat in their padding.
One helper now builds the separator and the current segment for My Files,
Shared with me and Computers, with a flat button's side padding.

Grid tile names broke mid-word with a hyphen that is not in the name
("SteamSetup.e-xe"); turn Pango's inserted hyphens off.
2026-10-09 02:18:47 +02:00
nvrl 3b80e9bc8f test(core): make under-budget cache check deterministic
The timing ratio between a near-empty and a 2500-entry index measured the
CI runner's load and flaked on main. Plant an oversized index row behind
the running total instead: any budget check that consults the index would
evict and reseed, so the test fails without depending on wall-clock time.
2026-10-08 21:26:56 +02:00
nvrl dc8eb27c99 Merge pull request #34 from narrrl/fix/sync-unreadable-subfolder
fix(fuse): skip unreadable folders in sync, never evict local-only files
2026-10-08 20:59:55 +02:00
nvrl d13e74fc12 fix(gui,cli,core): offer the CAPTCHA in the browser when the web view cannot start (B195)
The verification window now runs in its own process, so the app survives
when WebKit cannot start its sandbox on secureblue, but the CAPTCHA still
could not be solved there (#29). WebKit binds the journal socket with
`--ro-bind-try`, bubblewrap only skips a missing path, not an unreadable
one, and WebKit has no switch to leave the socket out.

Proton Mail Bridge never uses a web view: it opens verify.proton.me in the
system browser and retries with the original challenge token, typed with
the offered methods. Do the same as a fallback.

- core: `browser_verification_url` and `browser_verified` build the page
  URL and the retry credential from the challenge
- gui: when the verification window fails, a dialog opens the page in the
  browser and continues the sign-in once the user is done
- cli: `pdfs login` handles a CAPTCHA the same way instead of failing
- i18n: translate the new dialog in every catalog
- docs: B195, TROUBLESHOOTING.md, ARCHITECTURE.md, changelog

Fixes #29
2026-10-08 19:02:29 +02:00
43 changed files with 5481 additions and 4883 deletions

No files matched your search

Generated
+4 -4
View File
@@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]]
name = "pdfs-cli"
version = "3.1.2"
version = "3.2.0"
dependencies = [
"anyhow",
"clap",
@@ -3490,7 +3490,7 @@ dependencies = [
[[package]]
name = "pdfs-core"
version = "3.1.2"
version = "3.2.0"
dependencies = [
"async-trait",
"base64 0.23.1",
@@ -3516,7 +3516,7 @@ dependencies = [
[[package]]
name = "pdfs-fuse"
version = "3.1.2"
version = "3.2.0"
dependencies = [
"async-trait",
"base64 0.23.1",
@@ -3538,7 +3538,7 @@ dependencies = [
[[package]]
name = "pdfs-gui"
version = "3.1.2"
version = "3.2.0"
dependencies = [
"anyhow",
"async-channel",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package]
version = "3.1.2"
version = "3.2.0"
edition = "2024"
license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"]
+22 -13
View File
@@ -1843,20 +1843,29 @@ fn cmd_login(username: Option<String>, remember: bool) -> Result<()> {
// Only a two-password account is ever asked for this.
let get_mailbox = |attempt: u32| prompt_mailbox(attempt, remember);
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, get_mailbox))
.map_err(|e| match e {
pdfs_core::Error::HumanVerificationRequired(_) => anyhow::anyhow!(
// Proton may ask for a CAPTCHA first, usually on a VPN. The CLI has no
// browser engine, so the user solves the page in their own browser and says
// when they are done; the retry then carries the challenge token.
let get_hv = |hv: pdfs_core::proton_sdk::api::HumanVerification| {
println!(
"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"),
})?;
Open this page in your browser and complete it:\n\n {}\n",
auth::browser_verification_url(&hv)
);
prompt_line("Press Enter once the page says you are verified")
.map_err(|e| pdfs_core::Error::Other(format!("read verification answer: {e}")))?;
Ok(auth::browser_verified(&hv))
};
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(auth::login_interactive(
&username,
&password,
get_totp,
get_mailbox,
get_hv,
))
.context("login failed")?;
// A daemon that is already up is parked in `wait_for_session`, sleeping
// between keyring checks; restart it so the mount appears now instead of
+62
View File
@@ -206,6 +206,44 @@ pub async fn login_interactive(
}
}
/// Proton's verification page for the user's own browser, for a front-end that
/// cannot host it in a webview.
///
/// Unlike [`HumanVerification::verification_url`] this asks for the full page,
/// not the embedded one: a browser tab has no host to post the token to, so the
/// page has to tell the user they are done instead. Proton Mail Bridge opens the
/// same URL.
pub fn browser_verification_url(hv: &HumanVerification) -> String {
let token: String = hv
.token
.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(b as char).to_string()
}
_ => format!("%{b:02X}"),
})
.collect();
format!(
"https://verify.proton.me/?methods={}&token={token}",
hv.methods.join(","),
)
}
/// The credential to retry with once the user has solved the challenge in
/// their browser.
///
/// The browser keeps the page's own token, so the retry carries the challenge
/// token instead: solving the page marks that token as verified on Proton's
/// side. The type is every offered method, as Proton Mail Bridge sends it, since
/// the user may have picked any of them on the page.
pub fn browser_verified(hv: &HumanVerification) -> HumanVerificationCredential {
HumanVerificationCredential {
token: hv.token.clone(),
method: hv.methods.join(","),
}
}
/// [`login`], replaying a human-verification token the user has already earned.
///
/// A gated login fails with [`Error::HumanVerificationRequired`] carrying the
@@ -674,6 +712,30 @@ mod tests {
)
}
fn challenge() -> HumanVerification {
serde_json::from_str(
r#"{"HumanVerificationToken":"ab+c/d=","HumanVerificationMethods":["captcha","email"]}"#,
)
.unwrap()
}
#[test]
fn the_browser_gets_the_full_verification_page() {
assert_eq!(
browser_verification_url(&challenge()),
"https://verify.proton.me/?methods=captcha,email&token=ab%2Bc%2Fd%3D"
);
}
/// The browser keeps the page's token, so the retry replays the challenge
/// itself, typed with every method the page offered.
#[test]
fn a_browser_verification_retries_with_the_challenge_token() {
let credential = browser_verified(&challenge());
assert_eq!(credential.token, "ab+c/d=");
assert_eq!(credential.method, "captcha,email");
}
#[test]
fn passphrases_survive_the_blob_encoding() {
let decoded = decode(&encode(&passphrases())).expect("decodes");
+12 -28
View File
@@ -2309,42 +2309,26 @@ mod tests {
/// call also needs.
#[test]
fn budget_check_is_free_when_under_budget() {
use std::time::Instant;
// Cap far above what we store, so every store stays under budget.
let (c, _d) = cache_capped(1 << 30);
let payload = vec![0u8; 4096];
// Cost of a store against a near-empty index...
let t = Instant::now();
for i in 0..500u64 {
for i in 0..8u64 {
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
.unwrap();
}
let empty = t.elapsed() / 500;
// ...and against one with a few thousand entries in it.
for i in 500..2500u64 {
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
.unwrap();
}
let t = Instant::now();
for i in 2500..3000u64 {
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
.unwrap();
}
let full = t.elapsed() / 500;
// Plant an index row far over the cap behind the running total's back,
// and older than every real block. Any budget check that consults the
// index would see the pool over budget, evict, and reseed the total.
c.db.cache_touch("planted", KIND_BLOCK, 1 << 40, 0).unwrap();
println!("B4: store_block under budget — {empty:?} at ~0 entries, {full:?} at 2500");
// A ratio, not a wall-clock bound: the claim is that the under-budget
// path does not grow with the index, and only a ratio tests that. An
// absolute threshold measures the machine's load instead, and this test
// runs alongside the rest of the suite.
assert!(
full < empty * 3,
"under-budget store should not scale with the index: \
{empty:?} at ~0 entries vs {full:?} at 2500"
);
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, 8), &payload)
.unwrap();
// Not a timing ratio: that measured the CI runner's load and flaked.
assert_eq!(c.block_bytes.load(Ordering::Relaxed), 9 * 4096);
let entries = c.db.cache_entries_by_kind(KIND_BLOCK).unwrap();
assert_eq!(entries.len(), 10, "under-budget store evicted: {entries:?}");
}
/// The running totals are a fast path, not a second source of truth: after
+6
View File
@@ -1225,6 +1225,12 @@ pub struct JobItem {
/// Steps known to need doing, or `0` when unknown (indeterminate progress).
/// May *grow* mid-job as more work is discovered.
pub total: u64,
/// Housekeeping that changes nothing in the user's files: a mirror folder
/// scan that finds nothing to do yet, an index rebuild, a cache check. Front
/// ends may show it, but should not report the drive as busy over it. Absent
/// from older daemons, which never set it.
#[serde(default)]
pub background: bool,
}
/// One in-flight transfer in a [`Response::Transfers`] snapshot.
+1 -1
View File
@@ -710,7 +710,7 @@ fn scan_local_once(
// The walk has no idea how many files it will find, so the job counts what it
// has seen and stays indeterminate.
let job = transfers.begin_job("Indexing this computer");
let job = transfers.begin_background_job("Indexing this computer");
job.detail("Scanning your files");
let walked = localindex::scan(&[home], &excludes, |batch| {
if let Err(e) = db.local_upsert_batch(generation, &batch) {
+4
View File
@@ -657,12 +657,16 @@ impl Core {
} else {
p.total.max(p.done) as u64
},
// Most passes find nothing; only one that queues work is
// the drive syncing.
background: true,
},
SyncPhase::Applying => JobItem {
title: format!("Syncing {folder}"),
detail: p.current.clone(),
done: p.done as u64,
total: p.total.max(p.done) as u64,
background: false,
},
});
}
+3 -1
View File
@@ -1034,7 +1034,9 @@ impl Core {
let core = self.clone();
std::thread::spawn(move || {
let job = core.transfers.begin_job("Checking the cache database");
let job = core
.transfers
.begin_background_job("Checking the cache database");
job.detail("Verifying every page");
let problems = match core.db.integrity_check() {
Ok(problems) => problems,
+20 -12
View File
@@ -660,6 +660,12 @@ impl Core {
/// the shared uids; the per-node detail is then gathered best-effort — a single
/// node racing with an unshare drops from the list rather than failing the whole
/// request.
///
/// A share with no member, no invitation and no link is left out: it is
/// shared with no one. Stop Sharing leaves exactly that behind, since the SDK
/// cannot delete a share, and Drive keeps listing it until its backend gets
/// round to removing it, minutes later. A share whose details could not all
/// be read stays listed, so a failed lookup never hides a live share.
pub(crate) fn list_shared_by_me(&self) -> CoreResult<Vec<SharedItem>> {
let uids = self
.rt
@@ -678,24 +684,26 @@ impl Core {
let members = self
.rt
.block_on(self.client.list_share_members(&uid))
.map(|m| m.len())
.unwrap_or(0);
.map(|m| m.len());
let proton_invites = self
.rt
.block_on(self.client.list_share_invitations(&uid))
.map(|i| i.len())
.unwrap_or(0);
.map(|i| i.len());
let external_invites = self
.rt
.block_on(self.client.list_external_invitations(&uid))
.map(|i| i.len())
.unwrap_or(0);
let link = self
.rt
.block_on(self.client.get_public_link(&uid))
.ok()
.flatten()
.map(public_link_info);
.map(|i| i.len());
let link = self.rt.block_on(self.client.get_public_link(&uid));
if let (Ok(0), Ok(0), Ok(0), Ok(None)) =
(&members, &proton_invites, &external_invites, &link)
{
debug!(%uid, "shared by me: skipping an emptied share");
continue;
}
let members = members.unwrap_or(0);
let proton_invites = proton_invites.unwrap_or(0);
let external_invites = external_invites.unwrap_or(0);
let link = link.ok().flatten().map(public_link_info);
items.push(SharedItem {
uid: uid.to_string(),
is_dir: n.is_folder(),
+27 -1
View File
@@ -50,7 +50,7 @@ mod planner;
use engine::SYNC_CONCURRENCY;
pub(crate) use engine::{SyncMsg, spawn};
#[cfg(test)]
use engine::{classify, settle_with};
use engine::{classify, event_target, settle_with};
#[cfg(test)]
use local_scan::is_write_mode;
use local_scan::open_for_write_set;
@@ -2097,6 +2097,32 @@ mod tests {
(ids, started.elapsed())
}
#[test]
fn a_change_in_one_mirror_folder_reconciles_only_that_folder() {
let watched = vec![
(PathBuf::from("/home/me/Docs"), 1),
(PathBuf::from("/home/me/Photos"), 2),
(PathBuf::from("/home/me/Photos/Raw"), 3),
];
let target = |paths: &[&str]| {
let paths: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
match event_target(&watched, &paths) {
SyncMsg::Reconcile(id) => Some(id),
SyncMsg::ReconcileAll => None,
_ => panic!("unexpected message"),
}
};
assert_eq!(target(&["/home/me/Docs/a.txt"]), Some(1));
// A rename within one folder.
assert_eq!(target(&["/home/me/Docs/a", "/home/me/Docs/b"]), Some(1));
// The deepest root wins.
assert_eq!(target(&["/home/me/Photos/Raw/x.cr2"]), Some(3));
// Across folders, outside them all, or no path at all: walk everything.
assert_eq!(target(&["/home/me/Docs/a", "/home/me/Photos/a"]), None);
assert_eq!(target(&["/home/me/Docsx/a"]), None);
assert_eq!(target(&[]), None);
}
/// The bug this replaced a fixed sleep for: a save that keeps writing past
/// the debounce used to be walked mid-write, uploading a torn snapshot as a
/// real revision. The window has to restart on every event, so settling
+29 -3
View File
@@ -57,9 +57,10 @@ pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) -> Option<std::thread::Jo
fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
// Paths the watcher currently covers, mapped to their folder id, so an event
// path can be resolved back to the folder it belongs to.
let watched: Mutex<Vec<(PathBuf, i64)>> = Mutex::new(Vec::new());
let watched: Arc<Mutex<Vec<(PathBuf, i64)>>> = Arc::new(Mutex::new(Vec::new()));
let tx_events = core.sync_tx.clone();
let watched_events = watched.clone();
let mut watcher =
match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
let Ok(event) = res else { return };
@@ -67,7 +68,8 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
if matches!(event.kind, notify::EventKind::Access(_)) {
return;
}
let _ = tx_events.send(SyncMsg::ReconcileAll);
let msg = event_target(&watched_events.lock(), &event.paths);
let _ = tx_events.send(msg);
}) {
Ok(w) => w,
Err(e) => {
@@ -256,8 +258,31 @@ fn reconcile_all(core: &Core) {
}
}
/// The reconcile a watcher event asks for: the one mirror folder all its paths
/// lie in, so an edit in one folder does not walk every other one too. An event
/// that spans folders, or that no watched folder claims (a watch list a moment
/// out of date), falls back to every folder.
pub(super) fn event_target(watched: &[(PathBuf, i64)], paths: &[PathBuf]) -> SyncMsg {
let mut ids = paths.iter().map(|path| {
watched
.iter()
.filter(|(root, _)| path.starts_with(root))
// The deepest root, should one mirror folder sit inside another.
.max_by_key(|(root, _)| root.components().count())
.map(|(_, id)| *id)
});
match ids.next() {
Some(Some(first)) if ids.all(|id| id == Some(first)) => SyncMsg::Reconcile(first),
_ => SyncMsg::ReconcileAll,
}
}
/// Bring the filesystem watches in line with the current mirror-folder set:
/// watch newly-added folders, drop removed ones.
///
/// The watch calls run on a copy, outside the lock: the watcher's event
/// callback takes the same lock, and a backend that hands a watch request to
/// its event thread and waits for the answer would deadlock against it.
fn rewatch(
core: &Core,
watcher: &mut notify::RecommendedWatcher,
@@ -274,7 +299,7 @@ fn rewatch(
return;
}
};
let mut have = watched.lock();
let mut have = watched.lock().clone();
// Drop watches no longer wanted.
have.retain(|(path, _)| {
if want.iter().any(|(p, _)| p == path) {
@@ -294,6 +319,7 @@ fn rewatch(
Err(e) => warn!(path = %path.display(), error = %e, "sync: watch failed"),
}
}
*watched.lock() = have;
}
// ---- reconcile ------------------------------------------------------------
+17 -1
View File
@@ -46,6 +46,8 @@ struct JobEntry {
detail: Mutex<String>,
done: AtomicU64,
total: AtomicU64,
/// Housekeeping that moves nothing; see [`JobItem::background`].
background: bool,
}
/// The set of transfers and jobs currently in flight. Cloned `Arc`-style across
@@ -160,12 +162,25 @@ impl TransferRegistry {
/// that carries its progress and deregisters the job when dropped. The job
/// starts indeterminate; give it a `total` once one is known.
pub fn begin_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard {
self.register_job(title.into(), false)
}
/// [`TransferRegistry::begin_job`] for housekeeping the user did not ask
/// for and that changes nothing in their files, such as an index rebuild or
/// a cache check. It is reported, but front ends don't call the drive busy
/// over it.
pub fn begin_background_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard {
self.register_job(title.into(), true)
}
fn register_job(self: &Arc<Self>, title: String, background: bool) -> JobGuard {
let id = self.next.fetch_add(1, Ordering::Relaxed);
let entry = Arc::new(JobEntry {
title: title.into(),
title,
detail: Mutex::new(String::new()),
done: AtomicU64::new(0),
total: AtomicU64::new(0),
background,
});
self.jobs.lock().insert(id, entry.clone());
JobGuard {
@@ -191,6 +206,7 @@ impl TransferRegistry {
detail: e.detail.lock().clone(),
done: e.done.load(Ordering::Relaxed),
total: e.total.load(Ordering::Relaxed),
background: e.background,
}
})
.collect()
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Main menu: three bars. Bundled because themes such as Papirus draw
open-menu-symbolic as three dots, the same as every row's "more" menu. -->
<path fill="#222222" d="M 2,2.5 H 14 A 1,1 0 0 1 14,4.5 H 2 A 1,1 0 0 1 2,2.5 Z M 2,7 H 14 A 1,1 0 0 1 14,9 H 2 A 1,1 0 0 1 2,7 Z M 2,11.5 H 14 A 1,1 0 0 1 14,13.5 H 2 A 1,1 0 0 1 2,11.5 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 441 B

@@ -14,7 +14,6 @@
<file preprocess="xml-stripblanks">scalable/actions/pdfs-details-pane-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-link-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-map-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-menu-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-people-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-share-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-sync-symbolic.svg</file>
+6 -1
View File
@@ -18,7 +18,9 @@
.sidebar-status image.warning { color: var(--warning-color); }
.sidebar-status image.error { color: var(--error-color); }
.sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; }
.sidebar-account { padding: 4px 4px 4px 8px; }
/* The account row is the main menu's button: a row, not a pushbutton, so
* the name keeps the text weight. */
button.sidebar-account { padding: 4px 8px; font-weight: normal; }
/* A count of what waits on a page, at the end of its sidebar row */
.sidebar-badge {
min-width: 10px;
@@ -48,6 +50,9 @@
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
.badge-issue { color: var(--error-color); }
.browser-statusbar { background-color: alpha(currentColor, 0.025); }
/* The folder on screen, last in the breadcrumb trail: a flat button's side
* padding, so the "›" before it is spaced like the ones between buttons. */
.crumb-current { padding: 0 10px; }
scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
.dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
+2 -20
View File
@@ -34,6 +34,7 @@ use pages::verify::*;
use reload::{LoadTicket, Loader, replace_items};
use theme::{load_resources, proton_theme_active, set_proton_theme};
use widgets::compat::*;
use widgets::crumb::*;
use widgets::details::*;
use widgets::file_list::*;
use widgets::menu::*;
@@ -830,7 +831,6 @@ fn build_sidebar(footer: &gtk4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
let header = adw::HeaderBar::new();
header.set_title_widget(Some(&brand));
header.pack_end(&build_primary_menu());
let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never)
@@ -1153,25 +1153,7 @@ fn toast_failure(ui: &Rc<Ui>, what: &str, message: &str, kind: ErrorKind) {
}
}
/// The sidebar's primary (hamburger) menu: the app-level entries that don't
/// belong on any one page.
fn build_primary_menu() -> gtk4::MenuButton {
let menu = gio::Menu::new();
menu.append(Some(&gettext("Preferences")), Some("win.preferences"));
menu.append(Some(&gettext("Keyboard Shortcuts")), Some("win.shortcuts"));
menu.append(
Some(&gettext("About Proton Drive for Linux")),
Some("win.about"),
);
gtk4::MenuButton::builder()
.icon_name("pdfs-menu-symbolic")
.tooltip_text(gettext("Main menu"))
.primary(true)
.menu_model(&menu)
.build()
}
/// Back the primary and account menus' entries with window actions.
/// Back the main menu's entries with window actions.
fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
let preferences = gio::SimpleAction::new("preferences", None);
let ui_prefs = ui.clone();
+2 -9
View File
@@ -1220,9 +1220,7 @@ pub(crate) fn repaint_crumb(ui: &Rc<Ui>, path: &str) {
));
let mut acc = String::new();
for (i, seg) in segments.iter().enumerate() {
let sep = gtk4::Label::new(Some("›"));
sep.add_css_class("dim-label");
ui.browser.crumb.append(&sep);
ui.browser.crumb.append(&crumb_separator());
acc = if acc.is_empty() {
seg.to_string()
} else {
@@ -1237,12 +1235,7 @@ pub(crate) fn repaint_crumb(ui: &Rc<Ui>, path: &str) {
/// flat button that navigates to `target` (clearing any active search first).
pub(crate) fn crumb_node(ui: &Rc<Ui>, label: &str, target: &str, current: bool) -> gtk4::Widget {
if current {
let l = gtk4::Label::builder()
.label(label)
.ellipsize(gtk4::pango::EllipsizeMode::Start)
.build();
l.add_css_class("heading");
return l.upcast();
return crumb_current(label);
}
let button = gtk4::Button::builder().label(label).build();
button.add_css_class("flat");
+2 -9
View File
@@ -487,9 +487,7 @@ fn repaint_device_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
nav.is_empty(),
));
for (i, (_, name)) in nav.iter().enumerate() {
let sep = gtk4::Label::new(Some("›"));
sep.add_css_class("dim-label");
crumb.append(&sep);
crumb.append(&crumb_separator());
crumb.append(&device_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
}
}
@@ -498,12 +496,7 @@ fn repaint_device_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
/// that goes back up to `depth` levels below the device list.
fn device_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
if current {
let l = gtk4::Label::builder()
.label(label)
.ellipsize(gtk4::pango::EllipsizeMode::Start)
.build();
l.add_css_class("heading");
return l.upcast();
return crumb_current(label);
}
let button = gtk4::Button::builder().label(label).build();
button.add_css_class("flat");
+80 -27
View File
@@ -1,5 +1,5 @@
use crate::*;
use pdfs_core::proton_sdk::api::HumanVerificationCredential;
use pdfs_core::proton_sdk::api::{HumanVerification, HumanVerificationCredential};
pub(crate) struct LoginState {
// Login page.
@@ -150,26 +150,24 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
});
// 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. A
// window that never came up is told apart from one the user closed, so
// the result below can say why the sign-in stopped.
// sign-in, and the worker is blocked on the credential until it does.
let ui_hv = ui.clone();
let verification_failed = Rc::new(Cell::new(false));
let hv_failed = verification_failed.clone();
glib::spawn_future_local(async move {
if let Ok((url, token_tx)) = hv_req_rx.recv().await {
if let Ok((hv, credential_tx)) = hv_req_rx.recv().await {
ui_hv
.login
.login_status
.set_text(&gettext("Complete the verification to continue…"));
// Anything but a token drops the sender, which the worker reads
// as a cancelled sign-in.
match verify_human(url).await {
// Anything but a credential drops the sender, which the worker
// reads as a cancelled sign-in.
match verify_human(hv.verification_url()).await {
Verification::Solved(token) => {
let _ = token_tx.send(token);
let _ = credential_tx.send(HumanVerificationCredential::captcha(token));
}
Verification::Cancelled => {}
Verification::Failed => hv_failed.set(true),
// The web view could not start, most likely its sandbox
// (B195): the user's browser can show the page instead.
Verification::Failed => verify_in_browser(&ui_hv, &hv, credential_tx),
}
}
});
@@ -206,9 +204,6 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
let _ = gio::spawn_blocking(service::enable_start).await;
refresh(&ui);
}
Err(_) if verification_failed.get() => ui.login.login_status.set_text(&gettext(
"The verification page couldn't be opened, so the sign-in was cancelled. Your system may keep the web view's sandbox from starting; the troubleshooting guide explains what to do.",
)),
Err(e) => ui.login.login_status.set_text(&e),
}
});
@@ -267,9 +262,12 @@ pub(crate) struct LoginChannels {
pub(crate) result: async_channel::Receiver<Result<(), String>>,
/// Fires only if the account needs a 2FA code; carries the sender for it.
pub(crate) totp: async_channel::Receiver<std::sync::mpsc::Sender<String>>,
/// Fires only if the sign-in is gated: the page to show, and the sender for
/// the token the user earns on it.
pub(crate) hv: async_channel::Receiver<(String, std::sync::mpsc::Sender<String>)>,
/// Fires only if the sign-in is gated: the challenge, and the sender for the
/// credential the user earns by solving it.
pub(crate) hv: async_channel::Receiver<(
HumanVerification,
std::sync::mpsc::Sender<HumanVerificationCredential>,
)>,
/// Fires only for a two-password account: how many answers were already
/// wrong, and the sender for the password and the "remember" choice.
pub(crate) mailbox: async_channel::Receiver<(u32, std::sync::mpsc::Sender<(String, bool)>)>,
@@ -327,18 +325,16 @@ pub(crate) fn spawn_login(username: String, password: String) -> LoginChannels {
})
},
|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(|_| {
// Gated: hand the UI the challenge, block on the credential
// the user earns by solving it.
let (credential_tx, credential_rx) =
std::sync::mpsc::channel::<HumanVerificationCredential>();
hv_req_tx.send_blocking((hv, credential_tx)).map_err(|_| {
pdfs_core::Error::Other("verification prompt closed".into())
})?;
let token = token_rx
credential_rx
.recv()
.map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))?;
Ok(HumanVerificationCredential::captcha(token))
.map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))
},
)
.await
@@ -525,6 +521,63 @@ pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>)
dialog.present(parent.as_ref());
}
/// Let the user solve the challenge in their own browser, for when the
/// verification window could not come up. The page cannot hand its token back
/// to us, so the user says when they are done, and the retry carries the
/// challenge token, which the solved page made good (see
/// [`auth::browser_verified`]).
fn verify_in_browser(
ui: &Rc<Ui>,
hv: &HumanVerification,
credential_tx: std::sync::mpsc::Sender<HumanVerificationCredential>,
) {
let dialog = adw::AlertDialog::builder()
.heading(gettext("Verify in Your Browser"))
.body(gettext(
"The verification page can't be shown in the app on this system. Open it in your browser, complete it there, then come back and choose Continue.",
))
.build();
let url = auth::browser_verification_url(hv);
let open = gtk4::Button::builder()
.label(gettext("Open Verification Page"))
.halign(gtk4::Align::Center)
.build();
open.add_css_class("pill");
dialog.set_extra_child(Some(&open));
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("continue", &gettext("Continue"));
dialog.set_response_appearance("continue", adw::ResponseAppearance::Suggested);
// Continuing before the page was even opened would only fail the retry.
dialog.set_response_enabled("continue", false);
dialog.set_default_response(Some("continue"));
dialog.set_close_response("cancel");
let dialog_weak = dialog.downgrade();
open.connect_clicked(move |_| {
open_uri(&url);
if let Some(dialog) = dialog_weak.upgrade() {
dialog.set_response_enabled("continue", true);
}
});
let credential = auth::browser_verified(hv);
let credential_tx = RefCell::new(Some(credential_tx));
dialog.connect_response(None, move |_, resp| {
// On cancel/close we take + drop `tx` without sending, so the worker's
// recv errors out and the login is reported as cancelled.
if let Some(tx) = credential_tx.borrow_mut().take()
&& resp == "continue"
{
let _ = tx.send(credential.clone());
}
});
let parent = ui.login.login_button.root().and_downcast::<gtk4::Window>();
dialog.present(parent.as_ref());
}
/// Sign out, after a confirmation: disable+stop the mount service (so the
/// daemon isn't left running without credentials), forget the stored session,
/// and drop back to the login page.
+2 -9
View File
@@ -484,9 +484,7 @@ fn repaint_shared_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
nav.is_empty(),
));
for (i, (_, name)) in nav.iter().enumerate() {
let sep = gtk4::Label::new(Some("›"));
sep.add_css_class("dim-label");
crumb.append(&sep);
crumb.append(&crumb_separator());
crumb.append(&shared_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
}
}
@@ -495,12 +493,7 @@ fn repaint_shared_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
/// that goes back up to `depth` folders below the top level.
fn shared_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
if current {
let l = gtk4::Label::builder()
.label(label)
.ellipsize(gtk4::pango::EllipsizeMode::Start)
.build();
l.add_css_class("heading");
return l.upcast();
return crumb_current(label);
}
let button = gtk4::Button::builder().label(label).build();
button.add_css_class("flat");
@@ -565,6 +565,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect();
dialog.connect_response(Some("stop"), move |_, _| {
let done = done.clone();
let stopped = uids.clone();
run_requests(
&ui_c,
uids.iter()
@@ -572,6 +573,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
.collect(),
gettext_noop("Couldn't stop sharing"),
move |ui| {
forget_shared_by_me(ui, &stopped);
reload_listing(ui);
toast(ui, &done);
},
@@ -580,6 +582,18 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
dialog.present(ui_window(ui).as_ref());
}
/// Take items whose sharing just stopped off the page now, rather than when
/// the reload that confirms it comes back: that reload asks after every share
/// in turn and takes a while with many of them.
fn forget_shared_by_me(ui: &Rc<Ui>, uids: &[String]) {
let items = {
let mut items = ui.shared_by_me.items.borrow_mut();
items.retain(|item| !uids.contains(&item.uid));
items.clone()
};
repaint_shared_by_me(ui, &items);
}
/// Open a shared item: a folder in My Files, a file the way My Files would.
fn open_shared_by_me(ui: &Rc<Ui>, entry: &DirEntry) {
if entry.is_dir {
+24 -13
View File
@@ -381,23 +381,34 @@ pub(crate) fn build_main_page() -> MainWidgets {
.hexpand(true)
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
.build();
// The app's one menu, on the account row the way Proton's own apps keep it:
// app-wide entries first, the account's last. The whole row opens it, and
// F10 too, as the primary menu.
let app_section = gio::Menu::new();
app_section.append(Some(&gettext("Preferences")), Some("win.preferences"));
app_section.append(Some(&gettext("Keyboard Shortcuts")), Some("win.shortcuts"));
app_section.append(
Some(&gettext("About Proton Drive for Linux")),
Some("win.about"),
);
let account_section = gio::Menu::new();
account_section.append(Some(&gettext("Sign Out…")), Some("win.sign-out"));
let account_menu = gio::Menu::new();
account_menu.append(Some(&gettext("Preferences")), Some("win.preferences"));
let sign_out = gio::Menu::new();
sign_out.append(Some(&gettext("Sign Out…")), Some("win.sign-out"));
account_menu.append_section(None, &sign_out);
let account_button = gtk4::MenuButton::builder()
.icon_name("view-more-symbolic")
.tooltip_text(gettext("Account"))
account_menu.append_section(None, &app_section);
account_menu.append_section(None, &account_section);
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
row.append(&avatar);
row.append(&account_name);
row.append(&gtk4::Image::from_icon_name("view-more-symbolic"));
let account = gtk4::MenuButton::builder()
.child(&row)
.tooltip_text(gettext("Main menu"))
.menu_model(&account_menu)
.valign(gtk4::Align::Center)
.direction(gtk4::ArrowType::Up)
.primary(true)
.build();
account_button.add_css_class("flat");
let account = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
account.add_css_class("flat");
account.add_css_class("sidebar-account");
account.append(&avatar);
account.append(&account_name);
account.append(&account_button);
let footer = gtk4::Box::new(gtk4::Orientation::Vertical, 10);
footer.add_css_class("sidebar-footer");
+21
View File
@@ -0,0 +1,21 @@
use crate::*;
/// The "›" between two breadcrumb segments.
pub(crate) fn crumb_separator() -> gtk4::Label {
let sep = gtk4::Label::new(Some("›"));
sep.add_css_class("dim-label");
sep
}
/// The last breadcrumb segment, the folder on screen: a heading rather than a
/// button. It carries a flat button's side padding (`.crumb-current`), so the
/// "›" before it sits as far from it as from the button before.
pub(crate) fn crumb_current(label: &str) -> gtk4::Widget {
let l = gtk4::Label::builder()
.label(label)
.ellipsize(gtk4::pango::EllipsizeMode::Start)
.build();
l.add_css_class("heading");
l.add_css_class("crumb-current");
l.upcast()
}
+6 -1
View File
@@ -132,8 +132,13 @@ impl FileList {
// and the grid collapses to a single column. Allowing a mid-word
// break is what keeps the two-line-then-ellipsis budget below
// enforceable for *every* name rather than only the ones that
// happen to have spaces.
// happen to have spaces. Pango marks such a break with a hyphen
// that is not in the name ("SteamSetup.e-xe"); a file name is
// not prose, so break it bare, as Nautilus does.
let no_hyphens = gtk4::pango::AttrList::new();
no_hyphens.insert(gtk4::pango::AttrInt::new_insert_hyphens(false));
let label = gtk4::Label::builder()
.attributes(&no_hyphens)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.justify(gtk4::Justification::Center)
.max_width_chars(13)
+23 -9
View File
@@ -128,20 +128,34 @@ impl ActionMenu {
}
}
/// Where a menu opened over `anchor` should live in the widget tree: the
/// outermost grid or list view around it, or `anchor` itself. CSS descendant
/// selectors reach through a popover to its parent, and themes style the
/// insides of list items (`gridview > child box { margin: 12px }` in
/// Catppuccin); a menu parented to a tile picks those rules up and puts a wide
/// margin around every section. Under the view itself it is no item's child.
/// Where a menu opened over `anchor` should live in the widget tree.
///
/// Not inside a list item: CSS descendant selectors reach through a popover to
/// its parent, and themes style the insides of list items (`gridview > child
/// box { margin: 12px }` in Catppuccin); a menu parented to a tile picks those
/// rules up and puts a wide margin around every section. So the search starts
/// at the outermost grid or list view around `anchor`.
///
/// Not on a widget without a layout manager either: a popover is sized and
/// placed again when its parent's layout manager allocates it. The list views
/// and `ScrolledWindow` have none, so a menu parented there kept the size it was
/// first given, often too small, and opened scrolling. The host is the first
/// widget from there up that has one.
fn popover_host(anchor: &gtk4::Widget) -> gtk4::Widget {
let mut host = anchor.clone();
let mut start = anchor.clone();
let mut widget = anchor.parent();
while let Some(w) = widget {
widget = w.parent();
if w.is::<gtk4::GridView>() || w.is::<gtk4::ListView>() || w.is::<gtk4::ColumnView>() {
host = w;
start = w;
}
}
host
let mut host = Some(start.clone());
while let Some(w) = host {
if w.layout_manager().is_some() {
return w;
}
host = w.parent();
}
start
}
+1
View File
@@ -1,4 +1,5 @@
pub(crate) mod compat;
pub(crate) mod crumb;
pub(crate) mod details;
pub(crate) mod file_list;
pub(crate) mod menu;
+68 -48
View File
@@ -127,6 +127,26 @@ impl Drop for Busy {
}
}
/// A section's action, such as Send Invitations. It goes under the section's
/// rows, at their right edge, rather than in a row of its own: a row in a boxed
/// list reads as a setting, and a button alone in one looks misplaced.
fn group_button(label: &str) -> gtk4::Button {
let button = gtk4::Button::builder()
.label(label)
.halign(gtk4::Align::End)
.margin_top(12)
.build();
button.add_css_class("suggested-action");
button
}
/// A section's only row while the share is being read.
fn loading_row() -> adw::ActionRow {
let row = adw::ActionRow::builder().title(gettext("Loading…")).build();
row.add_prefix(&spinner());
row
}
/// How an open Share dialog addresses its node.
///
/// A node the browser is showing has a mountpoint-relative path. A node reached
@@ -287,26 +307,17 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
.selected(0)
.valign(gtk4::Align::Center)
.build();
let role_wrap = adw::ActionRow::builder().title(gettext("Role")).build();
role_wrap.add_suffix(&role_drop);
// The role goes with the addresses it applies to, at the end of their row.
role_drop.set_tooltip_text(Some(&gettext("Role")));
email_row.add_suffix(&role_drop);
let message_row = adw::EntryRow::builder()
.title(gettext("Message (optional)"))
.build();
let invite_btn = gtk4::Button::builder()
.label(gettext("Send Invitations"))
.halign(gtk4::Align::End)
.margin_top(6)
.build();
invite_btn.add_css_class("suggested-action");
let invite_btn = group_button(&gettext("Send Invitations"));
invite_btn.set_sensitive(false);
let invite_wrap = adw::PreferencesRow::builder()
.activatable(false)
.child(&invite_btn)
.build();
invite_group.add(&email_row);
invite_group.add(&role_wrap);
invite_group.add(&message_row);
invite_group.add(&invite_wrap);
invite_group.add(&invite_btn);
let people = adw::PreferencesGroup::builder()
.title(gettext("People with access"))
@@ -315,21 +326,18 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
.title(gettext("Public link"))
.build();
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
content.set_margin_top(12);
content.set_margin_bottom(12);
content.set_margin_start(12);
content.set_margin_end(12);
content.append(&invite_group);
content.append(&people);
content.append(&link_group);
let clamp = adw::Clamp::builder().child(&content).build();
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.hscrollbar_policy(gtk4::PolicyType::Never)
.child(&clamp)
.build();
toolbar.set_content(Some(&scroll));
// Until the share is read, each section says so rather than standing
// empty under its title.
let people_loading = loading_row();
people.add(&people_loading);
let link_loading = loading_row();
link_group.add(&link_loading);
let content = adw::PreferencesPage::new();
content.add(&invite_group);
content.add(&people);
content.add(&link_group);
toolbar.set_content(Some(&content));
let dialog = adw::Dialog::builder()
.title(pgettext("verb", "Share"))
@@ -343,8 +351,8 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
target,
people,
link_group,
people_rows: RefCell::new(Vec::new()),
link_rows: RefCell::new(Vec::new()),
people_rows: RefCell::new(vec![people_loading.upcast()]),
link_rows: RefCell::new(vec![link_loading.upcast()]),
});
// Check the addresses as they are typed: a malformed one marks the row as
@@ -430,17 +438,35 @@ pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
repaint_share_link(&state, link.as_ref());
}
Ok(Ok(Response::Error { message, .. })) => {
share_dialog_unread(&state);
toast_error(&state.ui, &gettext("Couldn't load sharing"), &message)
}
_ => toast_error(
_ => {
share_dialog_unread(&state);
toast_error(
&state.ui,
&gettext("Couldn't load sharing"),
&gettext("The Proton Drive service didn't respond."),
),
)
}
}
});
}
/// The share couldn't be read: what the sections showed (rows still loading,
/// or ones that may now be stale) gives way to one line that says so.
fn share_dialog_unread(state: &Rc<ShareDialog>) {
for row in state.people_rows.borrow_mut().drain(..) {
state.people.remove(&row);
}
for row in state.link_rows.borrow_mut().drain(..) {
state.link_group.remove(&row);
}
let row = dim_row(&gettext("Couldn't load sharing"));
state.people.add(&row);
state.people_rows.borrow_mut().push(row.upcast());
}
/// Rebuild the "People with access" rows from a fresh share listing.
pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntry]) {
for row in state.people_rows.borrow_mut().drain(..) {
@@ -455,15 +481,18 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
return;
}
for entry in entries {
// A member is the plain case and needs no word; an invitation says
// it is still waiting on the other side.
let subtitle = match entry.kind {
ShareEntryKind::Member => gettext("Member"),
ShareEntryKind::ProtonInvite => gettext("Invited (pending)"),
ShareEntryKind::ExternalInvite => gettext("Invited (external, pending)"),
ShareEntryKind::Member => String::new(),
ShareEntryKind::ProtonInvite => gettext("Invitation pending"),
ShareEntryKind::ExternalInvite => gettext("Invitation pending, no Proton account yet"),
};
let row = adw::ActionRow::builder()
.title(&entry.email)
.subtitle(&subtitle)
.build();
row.add_prefix(&adw::Avatar::new(32, Some(&entry.email), true));
// External invites can't have their role changed; show it read-only.
// Members and Proton invites get a role dropdown.
@@ -653,16 +682,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
.build();
let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build();
expiry_row.add_suffix(&expiry_drop);
let create = gtk4::Button::builder()
.label(gettext("Create Public Link"))
.halign(gtk4::Align::End)
.margin_top(6)
.build();
create.add_css_class("suggested-action");
let create_wrap = adw::PreferencesRow::builder()
.activatable(false)
.child(&create)
.build();
let create = group_button(&gettext("Create Public Link"));
// Enter in the password field creates the link.
let btn = create.clone();
@@ -689,11 +709,11 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
state.link_group.add(&role_row);
state.link_group.add(&pw_row);
state.link_group.add(&expiry_row);
state.link_group.add(&create_wrap);
state.link_group.add(&create);
rows.push(role_row.upcast());
rows.push(pw_row.upcast());
rows.push(expiry_row.upcast());
rows.push(create_wrap.upcast());
rows.push(create.upcast());
}
}
*state.link_rows.borrow_mut() = rows;
+60 -17
View File
@@ -88,8 +88,9 @@ struct DriveState {
/// Summarise a work snapshot into one menu line, or empty when idle. A single
/// transfer names the file and its percentage; several collapse to counts so the
/// menu stays one line regardless of queue depth. With nothing moving bytes, a
/// running job speaks for itself — a scan or an index rebuild is still "busy",
/// and the tray saying nothing there reads as "finished".
/// running job speaks for itself — a scan or an index rebuild is still work
/// going on, and the menu saying nothing there reads as "finished". The icon is
/// [`busy`]'s call, not this line's.
fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
match items {
[] => match jobs.first() {
@@ -143,12 +144,12 @@ struct DriveTray {
fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
match send(socket, &Request::Status) {
Ok(status @ Response::Status { .. }) => {
// Same daemon is up, so a cheap follow-up request gives the sync line.
let sync = match send(socket, &Request::GetQueueStatus) {
Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs),
_ => String::new(),
// Same daemon is up, so a cheap follow-up request says what moves.
let (items, jobs) = match send(socket, &Request::GetQueueStatus) {
Ok(Response::Transfers { items, jobs }) => (items, jobs),
_ => (Vec::new(), Vec::new()),
};
status_state(&status, sync, default_mountpoint)
status_state(&status, &items, &jobs, default_mountpoint)
}
// Socket answered but with something unexpected — treat as up but odd.
Ok(_) => unexpected_reply(default_mountpoint),
@@ -217,9 +218,14 @@ fn unexpected_reply(default_mountpoint: &Path) -> DriveState {
}
}
/// The state a running daemon's [`Response::Status`] describes, with `sync`
/// saying what it is moving right now.
fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> DriveState {
/// The state a running daemon's [`Response::Status`] describes, with `items`
/// and `jobs` saying what it is working on right now.
fn status_state(
status: &Response,
items: &[TransferItem],
jobs: &[JobItem],
default_mountpoint: &Path,
) -> DriveState {
let Response::Status {
mountpoint,
pinned,
@@ -239,7 +245,7 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
paused,
failing_ops,
online,
queued.is_some() || !sync.is_empty(),
queued.is_some() || busy(items, jobs),
);
DriveState {
line: match (online, queued) {
@@ -278,11 +284,19 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
mounted: true,
paused,
mountpoint: PathBuf::from(mountpoint),
sync,
sync: sync_line(items, jobs),
failing: failing_ops,
}
}
/// Whether the daemon is changing something: bytes moving, or a job the user
/// is waiting on. A mirror folder's routine scan and the like are background
/// jobs; they still get the menu line, but the icon spinning every two minutes
/// for a scan that finds nothing reads as constant syncing.
fn busy(items: &[TransferItem], jobs: &[JobItem]) -> bool {
!items.is_empty() || jobs.iter().any(|j| !j.background)
}
/// The phase a running mount is in. A pause is the user's own doing and says
/// so first; failures outrank everything else the daemon reports.
fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase {
@@ -547,11 +561,7 @@ fn main() -> Result<()> {
heard.take(feed);
}
let st = match &heard.status {
Some(status) => status_state(
status,
sync_line(&heard.items, &heard.jobs),
&default_mountpoint,
),
Some(status) => status_state(status, &heard.items, &heard.jobs, &default_mountpoint),
// Lost the daemon: ask once, which describes the sign-in instead.
None => poll_state(&socket, &default_mountpoint),
};
@@ -596,6 +606,39 @@ mod tests {
assert_eq!(phase_of(false, 0, true, false), Phase::Synced);
}
fn job(title: &str, background: bool) -> JobItem {
JobItem {
title: title.to_string(),
detail: String::new(),
done: 0,
total: 0,
background,
}
}
#[test]
fn a_routine_scan_is_not_busy_but_a_sync_is() {
assert!(!busy(&[], &[]));
assert!(!busy(&[], &[job("Checking Documents", true)]));
assert!(busy(
&[],
&[
job("Checking Documents", true),
job("Syncing Photos", false)
]
));
assert!(busy(&[], &[job("Uploading files", false)]));
}
#[test]
fn a_job_from_an_older_daemon_counts_as_busy() {
let job: JobItem = serde_json::from_str(
r#"{"title":"Checking Documents","detail":"","done":0,"total":0}"#,
)
.unwrap();
assert!(!job.background);
}
#[test]
fn the_issues_item_counts_in_the_singular_and_plural() {
assert_eq!(issues_label(1), "1 issue — View");
+7 -3
View File
@@ -571,9 +571,13 @@ sequenceDiagram
- The page may post the token as an object or as a JSON string; both are accepted.
- The page runs in a WebKitGTK view inside a child `pdfs-app`. WebKit aborts its process when its
sandbox cannot start, as on hardened systems such as secureblue (B195). In the child, that ends
the verification, and the app says why, instead of closing the app.
- The CLI has no web view. It fails with a message to sign in once with `pdfs-app`; the stored
session then works for the CLI and the service.
the web view, not the app.
- When the child fails, the app offers the page in the user's own browser instead, as Proton Mail
Bridge does. A browser tab cannot post the token back, so the user says when they are done, and
the retry carries the challenge token itself, which the solved page made good. Its type header
lists every offered method (`auth::browser_verified`).
- The CLI has no web view, so it always takes the browser path: it prints the page's URL and waits
for Enter.
---
+42 -6
View File
@@ -81,6 +81,7 @@ original wording.
| Entry | Title | What is left |
|---|---|---|
| [B199](#b199--a-stopped-share-stays-on-the-shared-by-me-page-for-minutes) | A stopped share stays on the Shared by me page for minutes | The share itself is not deleted; the SDK has no call for it |
| [B88](#b88--pdfs-pin-rename-move-delete-and-sharing-still-cannot-name-a-path-under-a-secondary-mount) | `pdfs pin`, `rename`, `move`, `delete` and sharing still cannot name a path under a secondary mount | `pin`, `unpin`, opening a file, thumbnails, uploads, sharing and versions |
| [B72](#b72--pdfs-trash-never-returns) | `pdfs trash` never returns | The slowness of the first refresh is unmeasured |
| [B71](#b71--conflict-sweep-is-not-production-ready-auto-trash-loop-crit-13) | Conflict sweep is not production-ready (auto-trash loop, CRIT-13) | Items 5 to 10 of the entry |
@@ -301,6 +302,32 @@ a live account since.
Newest first.
### B199 — A stopped share stays on the Shared by me page for minutes
**Status:** Partly fixed. The share itself is still not deleted.
**Found:** 2026-10-09, reported from the app: after **Stop Sharing**, the row stayed, and an
older share showed with no people and no link.
**Where:** `crates/pdfs-fuse/src/sharing.rs`, `Core::stop_sharing_for_uid` and
`Core::list_shared_by_me`; `crates/pdfs-gui/src/app/pages/shared_by_me.rs`, `prompt_stop_sharing`
**Repro:** Share a folder with a person, then choose **Stop Sharing…** on the Shared by me page.
The row stays, now with "—" for people and link, until a reload some minutes later.
**Cause.** The Rust SDK has no call that deletes a share. Stop Sharing removes the public link, the
invitations and the members one by one, and leaves an empty share behind. Drive deletes an empty
share on its own, but only after a while, and until then `GET v2/volumes/{vid}/shares` still lists
it. The JS SDK ends `unshareNode` with `DELETE drive/shares/{shareId}?Force=1` for exactly this
reason.
**Fix.** The daemon leaves a share with no member, no invitation and no link out of the Shared by
me listing, unless one of those lookups failed. The app also drops a stopped item from the page as
soon as the request succeeds, before the confirming reload. Left: a `delete_share` call in
`proton-drive-rs`, made at the end of `stop_sharing_for_uid`, so the share is really gone.
**Test:** none automated; the listing needs a live account.
---
### B198 — Switching to online-only deletes local files that never reached Drive
**Status:** Fixed (unverified).
@@ -392,29 +419,38 @@ and still silences the report.
### B195 — The app crashes when Proton asks for a CAPTCHA on secureblue
**Status:** Fixed (unverified). Nobody has run the fix on secureblue yet.
**Status:** Fixed (unverified). On 2026-10-08 the reporter confirmed that the app survives, but
the CAPTCHA still could not be solved; the browser fallback below has not run on secureblue yet.
**Found:** 2026-10-07, [#29](https://github.com/narrrl/proton-drive-linux/issues/29). On
secureblue 44 (a hardened Fedora Atomic), `pdfs-app` aborted a few seconds after the sign-in, with
`bwrap: Can't get type of source /run/systemd/journal/socket: Permission denied` and
`Failed to fully launch dbus-proxy`.
**Where:** `crates/pdfs-gui/src/app/pages/verify.rs`, `crates/pdfs-gui/src/app/pages/login.rs`
**Where:** `crates/pdfs-gui/src/app/pages/verify.rs`, `crates/pdfs-gui/src/app/pages/login.rs`,
`crates/pdfs-core/src/auth.rs`, `crates/pdfs-cli/src/main.rs`
**Repro:** Sign in on secureblue from a network Proton asks a CAPTCHA for, such as a VPN.
**Cause.** The CAPTCHA page runs in a WebKitGTK view (B8). Before it loads anything, WebKit starts
its sandbox: bubblewrap, with the journal socket bound in, and `xdg-dbus-proxy`. secureblue keeps
bubblewrap from the socket, the proxy does not start, and WebKit ends its process with `g_error`.
WebKit binds the socket with `--ro-bind-try`, but bubblewrap skips only a missing path, not one it
may not read, and WebKit has no switch to leave the socket out.
The view was in the app's own process, so the whole app went down, and nothing in our code can
catch that.
**Fix.** The view runs in a child, `pdfs-app --human-verification`, that reads the URL on stdin
and prints the token on stdout. A child that exits without a token after a crash or a non-zero
status ends the sign-in with a message that says the page could not be opened. TROUBLESHOOTING.md
gives the cause and a one-time workaround. The sandbox is not switched off by the app: users choose
a hardened system for that isolation.
status no longer ends the sign-in: the app offers the page in the user's own browser, as Proton
Mail Bridge does. The tab cannot post the token back, so the user says when they are done, and the
retry carries the challenge token, typed with every offered method. `pdfs login` takes the same
path. The sandbox is not switched off by the app: users choose a hardened system for that
isolation. TROUBLESHOOTING.md keeps the one-time workaround as a last resort.
**Test:** unit tests `a_window_that_died_fails`, `a_window_closed_without_a_token_cancels`,
`a_token_counts_even_if_the_window_died_after_it` and `the_token_is_found_among_other_output`.
`a_token_counts_even_if_the_window_died_after_it`, `the_token_is_found_among_other_output`,
`the_browser_gets_the_full_verification_page` and
`a_browser_verification_retries_with_the_challenge_token`. The browser retry has not run against a
live challenge: Proton only asks for one from a network it distrusts.
On 2026-10-08 the child ran under real WebKit on Arch against a local page that posts a completion,
and handed back its token.
+28
View File
@@ -11,6 +11,16 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased]
### Fixed
- **A CAPTCHA could not be solved on hardened systems.** When the verification page cannot open in
the app, as on secureblue, the app now offers it in your browser and continues the sign-in
once you are done. `pdfs login` now handles a CAPTCHA the same way instead of failing.
([#29](https://github.com/narrrl/proton-drive-linux/issues/29))
## [3.2.0] — 2026-10-08
Schema: **42**, unchanged.
### Fixed
- **One unreadable folder stopped a synced folder from syncing at all.** A folder you may not
read, such as a cache a container created, failed every pass, so nothing else in the synced
@@ -24,6 +34,22 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
- **Switching to online-only trashed synced files that had become ignored.** Adding a synced path
to `.pdfsignore` and then switching moved it to the Drive trash. Ignoring never deletes now, as
the user guide says.
- **A stopped share stayed on the Shared by me page for minutes.** Stop Sharing now takes it off
the page at once, and an emptied share no longer shows with no people and no link.
- **The tray icon showed syncing every two minutes.** The routine check of a synced folder no
longer counts as syncing. Only work that changes files spins the icon. An edit in one synced
folder also no longer rescans all the others.
- **Right-click menus sometimes opened too short and had to be scrolled.**
- **The Share dialog looked unfinished.** Its buttons now sit under their sections instead of in a
row of their own. The role is chosen beside the addresses, people show with avatars, and both
sections show that they are loading.
- **Breadcrumbs spaced the "›" unevenly, and grid file names were broken with hyphens they do not
have.**
### Changed
- **One main menu.** The menu button beside "Proton Drive" is gone. Preferences, Keyboard
Shortcuts, About and Sign Out are all in the account row at the bottom of the sidebar. F10 opens
it.
### Added
- **Move another computer's backup to this one.** **Move to This Computer…** on the Computers page,
@@ -1686,6 +1712,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
by a new POSIX compliance suite for the filesystem.
[3.2.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.2.0
[3.1.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.2
[3.1.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.1
+9 -7
View File
@@ -69,14 +69,16 @@ pdfs status
## Signing in
**"Proton is asking for a CAPTCHA"** — Proton wants human verification, usually on a VPN or an
unfamiliar network. The CLI cannot show it. Sign in with `pdfs-app`, which displays the challenge;
the stored session then works for the CLI too.
unfamiliar network. `pdfs login` prints the verification page's address: open it in your browser,
complete it, then press Enter. The app shows the page in its own window.
**"The verification page couldn't be opened"** — WebKit, which shows the CAPTCHA page, could not
start its sandbox. Hardened systems such as secureblue keep its bubblewrap from the journal socket,
and the log then shows `bwrap: Can't get type of source /run/systemd/journal/socket: Permission
denied` and `Failed to fully launch dbus-proxy`. Check that `bwrap` and `xdg-dbus-proxy` are
installed. If they are, sign in once with the sandbox switched off for this one run:
**"Verify in Your Browser"** — WebKit, which shows the CAPTCHA page in the app, could not start
its sandbox. Hardened systems such as secureblue keep its bubblewrap from the journal socket, and
the log then shows `bwrap: Can't get type of source /run/systemd/journal/socket: Permission
denied` and `Failed to fully launch dbus-proxy`. Choose **Open Verification Page**, complete the
page in your browser, then choose **Continue**. If that sign-in is refused too, check that `bwrap`
and `xdg-dbus-proxy` are installed. As a last resort, sign in once with the sandbox switched off
for this one run:
```bash
WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1 pdfs-app
+1 -1
View File
@@ -9,7 +9,7 @@
# Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux
pkgver=3.1.2
pkgver=3.2.0
pkgrel=1
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
arch=('x86_64')
+546 -516
View File
File diff suppressed because it is too large. Load diff
+544 -515
View File
File diff suppressed because it is too large. Load diff
+546 -515
View File
File diff suppressed because it is too large. Load diff
+545 -515
View File
File diff suppressed because it is too large. Load diff
+544 -515
View File
File diff suppressed because it is too large. Load diff
+545 -516
View File
File diff suppressed because it is too large. Load diff
+516 -510
View File
File diff suppressed because it is too large. Load diff
+545 -515
View File
File diff suppressed because it is too large. Load diff
+544 -515
View File
File diff suppressed because it is too large. Load diff