mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fd3439b60 | ||
|
|
c4847c4d52 | ||
|
|
ad49e32de9 | ||
|
|
19854b2d76 | ||
|
|
085335ca14 | ||
|
|
9fb7e84d41 | ||
|
|
7ebe8e5210 | ||
|
|
922399b9a2 | ||
|
|
3b80e9bc8f | ||
|
|
dc8eb27c99 | ||
|
|
d13e74fc12 |
No files matched your search
Generated
+4
-4
@@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "3.1.2"
|
version = "3.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -3490,7 +3490,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "3.1.2"
|
version = "3.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3516,7 +3516,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "3.1.2"
|
version = "3.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3538,7 +3538,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "3.1.2"
|
version = "3.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "3.1.2"
|
version = "3.2.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
|
|||||||
+22
-13
@@ -1843,20 +1843,29 @@ fn cmd_login(username: Option<String>, remember: bool) -> Result<()> {
|
|||||||
// Only a two-password account is ever asked for this.
|
// Only a two-password account is ever asked for this.
|
||||||
let get_mailbox = |attempt: u32| prompt_mailbox(attempt, remember);
|
let get_mailbox = |attempt: u32| prompt_mailbox(attempt, remember);
|
||||||
|
|
||||||
|
// 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\
|
||||||
|
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()?;
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
// Solving a CAPTCHA needs a browser engine, which the CLI has no business
|
rt.block_on(auth::login_interactive(
|
||||||
// carrying. Point at the app that does rather than failing with the raw API
|
&username,
|
||||||
// message, which reads as "your login is broken".
|
&password,
|
||||||
rt.block_on(auth::login(&username, &password, get_totp, get_mailbox))
|
get_totp,
|
||||||
.map_err(|e| match e {
|
get_mailbox,
|
||||||
pdfs_core::Error::HumanVerificationRequired(_) => anyhow::anyhow!(
|
get_hv,
|
||||||
"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\
|
.context("login failed")?;
|
||||||
(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"),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// A daemon that is already up is parked in `wait_for_session`, sleeping
|
// 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
|
// between keyring checks; restart it so the mount appears now instead of
|
||||||
|
|||||||
@@ -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.
|
/// [`login`], replaying a human-verification token the user has already earned.
|
||||||
///
|
///
|
||||||
/// A gated login fails with [`Error::HumanVerificationRequired`] carrying the
|
/// 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]
|
#[test]
|
||||||
fn passphrases_survive_the_blob_encoding() {
|
fn passphrases_survive_the_blob_encoding() {
|
||||||
let decoded = decode(&encode(&passphrases())).expect("decodes");
|
let decoded = decode(&encode(&passphrases())).expect("decodes");
|
||||||
|
|||||||
@@ -2309,42 +2309,26 @@ mod tests {
|
|||||||
/// call also needs.
|
/// call also needs.
|
||||||
#[test]
|
#[test]
|
||||||
fn budget_check_is_free_when_under_budget() {
|
fn budget_check_is_free_when_under_budget() {
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
// Cap far above what we store, so every store stays under budget.
|
// Cap far above what we store, so every store stays under budget.
|
||||||
let (c, _d) = cache_capped(1 << 30);
|
let (c, _d) = cache_capped(1 << 30);
|
||||||
let payload = vec![0u8; 4096];
|
let payload = vec![0u8; 4096];
|
||||||
|
for i in 0..8u64 {
|
||||||
// Cost of a store against a near-empty index...
|
|
||||||
let t = Instant::now();
|
|
||||||
for i in 0..500u64 {
|
|
||||||
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
|
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
let empty = t.elapsed() / 500;
|
|
||||||
|
|
||||||
// ...and against one with a few thousand entries in it.
|
// Plant an index row far over the cap behind the running total's back,
|
||||||
for i in 500..2500u64 {
|
// and older than every real block. Any budget check that consults the
|
||||||
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
|
// index would see the pool over budget, evict, and reseed the total.
|
||||||
.unwrap();
|
c.db.cache_touch("planted", KIND_BLOCK, 1 << 40, 0).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;
|
|
||||||
|
|
||||||
println!("B4: store_block under budget — {empty:?} at ~0 entries, {full:?} at 2500");
|
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, 8), &payload)
|
||||||
// A ratio, not a wall-clock bound: the claim is that the under-budget
|
.unwrap();
|
||||||
// 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
|
// Not a timing ratio: that measured the CI runner's load and flaked.
|
||||||
// runs alongside the rest of the suite.
|
assert_eq!(c.block_bytes.load(Ordering::Relaxed), 9 * 4096);
|
||||||
assert!(
|
let entries = c.db.cache_entries_by_kind(KIND_BLOCK).unwrap();
|
||||||
full < empty * 3,
|
assert_eq!(entries.len(), 10, "under-budget store evicted: {entries:?}");
|
||||||
"under-budget store should not scale with the index: \
|
|
||||||
{empty:?} at ~0 entries vs {full:?} at 2500"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The running totals are a fast path, not a second source of truth: after
|
/// The running totals are a fast path, not a second source of truth: after
|
||||||
|
|||||||
@@ -1225,6 +1225,12 @@ pub struct JobItem {
|
|||||||
/// Steps known to need doing, or `0` when unknown (indeterminate progress).
|
/// Steps known to need doing, or `0` when unknown (indeterminate progress).
|
||||||
/// May *grow* mid-job as more work is discovered.
|
/// May *grow* mid-job as more work is discovered.
|
||||||
pub total: u64,
|
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.
|
/// One in-flight transfer in a [`Response::Transfers`] snapshot.
|
||||||
|
|||||||
@@ -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
|
// The walk has no idea how many files it will find, so the job counts what it
|
||||||
// has seen and stays indeterminate.
|
// 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");
|
job.detail("Scanning your files");
|
||||||
let walked = localindex::scan(&[home], &excludes, |batch| {
|
let walked = localindex::scan(&[home], &excludes, |batch| {
|
||||||
if let Err(e) = db.local_upsert_batch(generation, &batch) {
|
if let Err(e) = db.local_upsert_batch(generation, &batch) {
|
||||||
|
|||||||
@@ -657,12 +657,16 @@ impl Core {
|
|||||||
} else {
|
} else {
|
||||||
p.total.max(p.done) as u64
|
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 {
|
SyncPhase::Applying => JobItem {
|
||||||
title: format!("Syncing {folder}"),
|
title: format!("Syncing {folder}"),
|
||||||
detail: p.current.clone(),
|
detail: p.current.clone(),
|
||||||
done: p.done as u64,
|
done: p.done as u64,
|
||||||
total: p.total.max(p.done) as u64,
|
total: p.total.max(p.done) as u64,
|
||||||
|
background: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1034,7 +1034,9 @@ impl Core {
|
|||||||
|
|
||||||
let core = self.clone();
|
let core = self.clone();
|
||||||
std::thread::spawn(move || {
|
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");
|
job.detail("Verifying every page");
|
||||||
let problems = match core.db.integrity_check() {
|
let problems = match core.db.integrity_check() {
|
||||||
Ok(problems) => problems,
|
Ok(problems) => problems,
|
||||||
|
|||||||
@@ -660,6 +660,12 @@ impl Core {
|
|||||||
/// the shared uids; the per-node detail is then gathered best-effort — a single
|
/// 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
|
/// node racing with an unshare drops from the list rather than failing the whole
|
||||||
/// request.
|
/// 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>> {
|
pub(crate) fn list_shared_by_me(&self) -> CoreResult<Vec<SharedItem>> {
|
||||||
let uids = self
|
let uids = self
|
||||||
.rt
|
.rt
|
||||||
@@ -678,24 +684,26 @@ impl Core {
|
|||||||
let members = self
|
let members = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.list_share_members(&uid))
|
.block_on(self.client.list_share_members(&uid))
|
||||||
.map(|m| m.len())
|
.map(|m| m.len());
|
||||||
.unwrap_or(0);
|
|
||||||
let proton_invites = self
|
let proton_invites = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.list_share_invitations(&uid))
|
.block_on(self.client.list_share_invitations(&uid))
|
||||||
.map(|i| i.len())
|
.map(|i| i.len());
|
||||||
.unwrap_or(0);
|
|
||||||
let external_invites = self
|
let external_invites = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.list_external_invitations(&uid))
|
.block_on(self.client.list_external_invitations(&uid))
|
||||||
.map(|i| i.len())
|
.map(|i| i.len());
|
||||||
.unwrap_or(0);
|
let link = self.rt.block_on(self.client.get_public_link(&uid));
|
||||||
let link = self
|
if let (Ok(0), Ok(0), Ok(0), Ok(None)) =
|
||||||
.rt
|
(&members, &proton_invites, &external_invites, &link)
|
||||||
.block_on(self.client.get_public_link(&uid))
|
{
|
||||||
.ok()
|
debug!(%uid, "shared by me: skipping an emptied share");
|
||||||
.flatten()
|
continue;
|
||||||
.map(public_link_info);
|
}
|
||||||
|
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 {
|
items.push(SharedItem {
|
||||||
uid: uid.to_string(),
|
uid: uid.to_string(),
|
||||||
is_dir: n.is_folder(),
|
is_dir: n.is_folder(),
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ mod planner;
|
|||||||
use engine::SYNC_CONCURRENCY;
|
use engine::SYNC_CONCURRENCY;
|
||||||
pub(crate) use engine::{SyncMsg, spawn};
|
pub(crate) use engine::{SyncMsg, spawn};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use engine::{classify, settle_with};
|
use engine::{classify, event_target, settle_with};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use local_scan::is_write_mode;
|
use local_scan::is_write_mode;
|
||||||
use local_scan::open_for_write_set;
|
use local_scan::open_for_write_set;
|
||||||
@@ -2097,6 +2097,32 @@ mod tests {
|
|||||||
(ids, started.elapsed())
|
(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 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
|
/// 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
|
/// real revision. The window has to restart on every event, so settling
|
||||||
|
|||||||
@@ -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>) {
|
fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
||||||
// Paths the watcher currently covers, mapped to their folder id, so an event
|
// Paths the watcher currently covers, mapped to their folder id, so an event
|
||||||
// path can be resolved back to the folder it belongs to.
|
// 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 tx_events = core.sync_tx.clone();
|
||||||
|
let watched_events = watched.clone();
|
||||||
let mut watcher =
|
let mut watcher =
|
||||||
match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
||||||
let Ok(event) = res else { return };
|
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(_)) {
|
if matches!(event.kind, notify::EventKind::Access(_)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _ = tx_events.send(SyncMsg::ReconcileAll);
|
let msg = event_target(&watched_events.lock(), &event.paths);
|
||||||
|
let _ = tx_events.send(msg);
|
||||||
}) {
|
}) {
|
||||||
Ok(w) => w,
|
Ok(w) => w,
|
||||||
Err(e) => {
|
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:
|
/// Bring the filesystem watches in line with the current mirror-folder set:
|
||||||
/// watch newly-added folders, drop removed ones.
|
/// 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(
|
fn rewatch(
|
||||||
core: &Core,
|
core: &Core,
|
||||||
watcher: &mut notify::RecommendedWatcher,
|
watcher: &mut notify::RecommendedWatcher,
|
||||||
@@ -274,7 +299,7 @@ fn rewatch(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut have = watched.lock();
|
let mut have = watched.lock().clone();
|
||||||
// Drop watches no longer wanted.
|
// Drop watches no longer wanted.
|
||||||
have.retain(|(path, _)| {
|
have.retain(|(path, _)| {
|
||||||
if want.iter().any(|(p, _)| p == 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"),
|
Err(e) => warn!(path = %path.display(), error = %e, "sync: watch failed"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*watched.lock() = have;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- reconcile ------------------------------------------------------------
|
// ---- reconcile ------------------------------------------------------------
|
||||||
@@ -46,6 +46,8 @@ struct JobEntry {
|
|||||||
detail: Mutex<String>,
|
detail: Mutex<String>,
|
||||||
done: AtomicU64,
|
done: AtomicU64,
|
||||||
total: 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
|
/// 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
|
/// that carries its progress and deregisters the job when dropped. The job
|
||||||
/// starts indeterminate; give it a `total` once one is known.
|
/// starts indeterminate; give it a `total` once one is known.
|
||||||
pub fn begin_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard {
|
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 id = self.next.fetch_add(1, Ordering::Relaxed);
|
||||||
let entry = Arc::new(JobEntry {
|
let entry = Arc::new(JobEntry {
|
||||||
title: title.into(),
|
title,
|
||||||
detail: Mutex::new(String::new()),
|
detail: Mutex::new(String::new()),
|
||||||
done: AtomicU64::new(0),
|
done: AtomicU64::new(0),
|
||||||
total: AtomicU64::new(0),
|
total: AtomicU64::new(0),
|
||||||
|
background,
|
||||||
});
|
});
|
||||||
self.jobs.lock().insert(id, entry.clone());
|
self.jobs.lock().insert(id, entry.clone());
|
||||||
JobGuard {
|
JobGuard {
|
||||||
@@ -191,6 +206,7 @@ impl TransferRegistry {
|
|||||||
detail: e.detail.lock().clone(),
|
detail: e.detail.lock().clone(),
|
||||||
done: e.done.load(Ordering::Relaxed),
|
done: e.done.load(Ordering::Relaxed),
|
||||||
total: e.total.load(Ordering::Relaxed),
|
total: e.total.load(Ordering::Relaxed),
|
||||||
|
background: e.background,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.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-details-pane-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-link-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-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-people-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-share-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>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-sync-symbolic.svg</file>
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
.sidebar-status image.warning { color: var(--warning-color); }
|
.sidebar-status image.warning { color: var(--warning-color); }
|
||||||
.sidebar-status image.error { color: var(--error-color); }
|
.sidebar-status image.error { color: var(--error-color); }
|
||||||
.sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; }
|
.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 */
|
/* A count of what waits on a page, at the end of its sidebar row */
|
||||||
.sidebar-badge {
|
.sidebar-badge {
|
||||||
min-width: 10px;
|
min-width: 10px;
|
||||||
@@ -48,6 +50,9 @@
|
|||||||
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
|
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
|
||||||
.badge-issue { color: var(--error-color); }
|
.badge-issue { color: var(--error-color); }
|
||||||
.browser-statusbar { background-color: alpha(currentColor, 0.025); }
|
.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; }
|
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); }
|
.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; }
|
.dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ use pages::verify::*;
|
|||||||
use reload::{LoadTicket, Loader, replace_items};
|
use reload::{LoadTicket, Loader, replace_items};
|
||||||
use theme::{load_resources, proton_theme_active, set_proton_theme};
|
use theme::{load_resources, proton_theme_active, set_proton_theme};
|
||||||
use widgets::compat::*;
|
use widgets::compat::*;
|
||||||
|
use widgets::crumb::*;
|
||||||
use widgets::details::*;
|
use widgets::details::*;
|
||||||
use widgets::file_list::*;
|
use widgets::file_list::*;
|
||||||
use widgets::menu::*;
|
use widgets::menu::*;
|
||||||
@@ -830,7 +831,6 @@ fn build_sidebar(footer: >k4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
|
|||||||
|
|
||||||
let header = adw::HeaderBar::new();
|
let header = adw::HeaderBar::new();
|
||||||
header.set_title_widget(Some(&brand));
|
header.set_title_widget(Some(&brand));
|
||||||
header.pack_end(&build_primary_menu());
|
|
||||||
|
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
.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
|
/// Back the main menu's entries with window actions.
|
||||||
/// 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.
|
|
||||||
fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
||||||
let preferences = gio::SimpleAction::new("preferences", None);
|
let preferences = gio::SimpleAction::new("preferences", None);
|
||||||
let ui_prefs = ui.clone();
|
let ui_prefs = ui.clone();
|
||||||
|
|||||||
@@ -1220,9 +1220,7 @@ pub(crate) fn repaint_crumb(ui: &Rc<Ui>, path: &str) {
|
|||||||
));
|
));
|
||||||
let mut acc = String::new();
|
let mut acc = String::new();
|
||||||
for (i, seg) in segments.iter().enumerate() {
|
for (i, seg) in segments.iter().enumerate() {
|
||||||
let sep = gtk4::Label::new(Some("›"));
|
ui.browser.crumb.append(&crumb_separator());
|
||||||
sep.add_css_class("dim-label");
|
|
||||||
ui.browser.crumb.append(&sep);
|
|
||||||
acc = if acc.is_empty() {
|
acc = if acc.is_empty() {
|
||||||
seg.to_string()
|
seg.to_string()
|
||||||
} else {
|
} 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).
|
/// 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 {
|
pub(crate) fn crumb_node(ui: &Rc<Ui>, label: &str, target: &str, current: bool) -> gtk4::Widget {
|
||||||
if current {
|
if current {
|
||||||
let l = gtk4::Label::builder()
|
return crumb_current(label);
|
||||||
.label(label)
|
|
||||||
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
|
||||||
.build();
|
|
||||||
l.add_css_class("heading");
|
|
||||||
return l.upcast();
|
|
||||||
}
|
}
|
||||||
let button = gtk4::Button::builder().label(label).build();
|
let button = gtk4::Button::builder().label(label).build();
|
||||||
button.add_css_class("flat");
|
button.add_css_class("flat");
|
||||||
|
|||||||
@@ -487,9 +487,7 @@ fn repaint_device_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
|||||||
nav.is_empty(),
|
nav.is_empty(),
|
||||||
));
|
));
|
||||||
for (i, (_, name)) in nav.iter().enumerate() {
|
for (i, (_, name)) in nav.iter().enumerate() {
|
||||||
let sep = gtk4::Label::new(Some("›"));
|
crumb.append(&crumb_separator());
|
||||||
sep.add_css_class("dim-label");
|
|
||||||
crumb.append(&sep);
|
|
||||||
crumb.append(&device_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
|
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.
|
/// 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 {
|
fn device_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
|
||||||
if current {
|
if current {
|
||||||
let l = gtk4::Label::builder()
|
return crumb_current(label);
|
||||||
.label(label)
|
|
||||||
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
|
||||||
.build();
|
|
||||||
l.add_css_class("heading");
|
|
||||||
return l.upcast();
|
|
||||||
}
|
}
|
||||||
let button = gtk4::Button::builder().label(label).build();
|
let button = gtk4::Button::builder().label(label).build();
|
||||||
button.add_css_class("flat");
|
button.add_css_class("flat");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::*;
|
use crate::*;
|
||||||
use pdfs_core::proton_sdk::api::HumanVerificationCredential;
|
use pdfs_core::proton_sdk::api::{HumanVerification, HumanVerificationCredential};
|
||||||
|
|
||||||
pub(crate) struct LoginState {
|
pub(crate) struct LoginState {
|
||||||
// Login page.
|
// 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
|
// 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
|
// sign-in, and the worker is blocked on the credential until it does.
|
||||||
// window that never came up is told apart from one the user closed, so
|
|
||||||
// the result below can say why the sign-in stopped.
|
|
||||||
let ui_hv = ui.clone();
|
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 {
|
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
|
ui_hv
|
||||||
.login
|
.login
|
||||||
.login_status
|
.login_status
|
||||||
.set_text(&gettext("Complete the verification to continue…"));
|
.set_text(&gettext("Complete the verification to continue…"));
|
||||||
// Anything but a token drops the sender, which the worker reads
|
// Anything but a credential drops the sender, which the worker
|
||||||
// as a cancelled sign-in.
|
// reads as a cancelled sign-in.
|
||||||
match verify_human(url).await {
|
match verify_human(hv.verification_url()).await {
|
||||||
Verification::Solved(token) => {
|
Verification::Solved(token) => {
|
||||||
let _ = token_tx.send(token);
|
let _ = credential_tx.send(HumanVerificationCredential::captcha(token));
|
||||||
}
|
}
|
||||||
Verification::Cancelled => {}
|
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;
|
let _ = gio::spawn_blocking(service::enable_start).await;
|
||||||
refresh(&ui);
|
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),
|
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>>,
|
pub(crate) result: async_channel::Receiver<Result<(), String>>,
|
||||||
/// Fires only if the account needs a 2FA code; carries the sender for it.
|
/// 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>>,
|
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
|
/// Fires only if the sign-in is gated: the challenge, and the sender for the
|
||||||
/// the token the user earns on it.
|
/// credential the user earns by solving it.
|
||||||
pub(crate) hv: async_channel::Receiver<(String, std::sync::mpsc::Sender<String>)>,
|
pub(crate) hv: async_channel::Receiver<(
|
||||||
|
HumanVerification,
|
||||||
|
std::sync::mpsc::Sender<HumanVerificationCredential>,
|
||||||
|
)>,
|
||||||
/// Fires only for a two-password account: how many answers were already
|
/// Fires only for a two-password account: how many answers were already
|
||||||
/// wrong, and the sender for the password and the "remember" choice.
|
/// wrong, and the sender for the password and the "remember" choice.
|
||||||
pub(crate) mailbox: async_channel::Receiver<(u32, std::sync::mpsc::Sender<(String, bool)>)>,
|
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| {
|
|hv| {
|
||||||
// Gated: hand the UI the page to show, block on the token the
|
// Gated: hand the UI the challenge, block on the credential
|
||||||
// user earns by solving it.
|
// the user earns by solving it.
|
||||||
let (token_tx, token_rx) = std::sync::mpsc::channel::<String>();
|
let (credential_tx, credential_rx) =
|
||||||
hv_req_tx
|
std::sync::mpsc::channel::<HumanVerificationCredential>();
|
||||||
.send_blocking((hv.verification_url(), token_tx))
|
hv_req_tx.send_blocking((hv, credential_tx)).map_err(|_| {
|
||||||
.map_err(|_| {
|
pdfs_core::Error::Other("verification prompt closed".into())
|
||||||
pdfs_core::Error::Other("verification prompt closed".into())
|
})?;
|
||||||
})?;
|
credential_rx
|
||||||
let token = token_rx
|
|
||||||
.recv()
|
.recv()
|
||||||
.map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))?;
|
.map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))
|
||||||
Ok(HumanVerificationCredential::captcha(token))
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.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());
|
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
|
/// Sign out, after a confirmation: disable+stop the mount service (so the
|
||||||
/// daemon isn't left running without credentials), forget the stored session,
|
/// daemon isn't left running without credentials), forget the stored session,
|
||||||
/// and drop back to the login page.
|
/// and drop back to the login page.
|
||||||
|
|||||||
@@ -484,9 +484,7 @@ fn repaint_shared_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
|||||||
nav.is_empty(),
|
nav.is_empty(),
|
||||||
));
|
));
|
||||||
for (i, (_, name)) in nav.iter().enumerate() {
|
for (i, (_, name)) in nav.iter().enumerate() {
|
||||||
let sep = gtk4::Label::new(Some("›"));
|
crumb.append(&crumb_separator());
|
||||||
sep.add_css_class("dim-label");
|
|
||||||
crumb.append(&sep);
|
|
||||||
crumb.append(&shared_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
|
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.
|
/// 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 {
|
fn shared_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
|
||||||
if current {
|
if current {
|
||||||
let l = gtk4::Label::builder()
|
return crumb_current(label);
|
||||||
.label(label)
|
|
||||||
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
|
||||||
.build();
|
|
||||||
l.add_css_class("heading");
|
|
||||||
return l.upcast();
|
|
||||||
}
|
}
|
||||||
let button = gtk4::Button::builder().label(label).build();
|
let button = gtk4::Button::builder().label(label).build();
|
||||||
button.add_css_class("flat");
|
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();
|
let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect();
|
||||||
dialog.connect_response(Some("stop"), move |_, _| {
|
dialog.connect_response(Some("stop"), move |_, _| {
|
||||||
let done = done.clone();
|
let done = done.clone();
|
||||||
|
let stopped = uids.clone();
|
||||||
run_requests(
|
run_requests(
|
||||||
&ui_c,
|
&ui_c,
|
||||||
uids.iter()
|
uids.iter()
|
||||||
@@ -572,6 +573,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
.collect(),
|
.collect(),
|
||||||
gettext_noop("Couldn't stop sharing"),
|
gettext_noop("Couldn't stop sharing"),
|
||||||
move |ui| {
|
move |ui| {
|
||||||
|
forget_shared_by_me(ui, &stopped);
|
||||||
reload_listing(ui);
|
reload_listing(ui);
|
||||||
toast(ui, &done);
|
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());
|
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.
|
/// 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) {
|
fn open_shared_by_me(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
if entry.is_dir {
|
if entry.is_dir {
|
||||||
|
|||||||
@@ -381,23 +381,34 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
|||||||
.hexpand(true)
|
.hexpand(true)
|
||||||
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
||||||
.build();
|
.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();
|
let account_menu = gio::Menu::new();
|
||||||
account_menu.append(Some(&gettext("Preferences")), Some("win.preferences"));
|
account_menu.append_section(None, &app_section);
|
||||||
let sign_out = gio::Menu::new();
|
account_menu.append_section(None, &account_section);
|
||||||
sign_out.append(Some(&gettext("Sign Out…")), Some("win.sign-out"));
|
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
||||||
account_menu.append_section(None, &sign_out);
|
row.append(&avatar);
|
||||||
let account_button = gtk4::MenuButton::builder()
|
row.append(&account_name);
|
||||||
.icon_name("view-more-symbolic")
|
row.append(>k4::Image::from_icon_name("view-more-symbolic"));
|
||||||
.tooltip_text(gettext("Account"))
|
let account = gtk4::MenuButton::builder()
|
||||||
|
.child(&row)
|
||||||
|
.tooltip_text(gettext("Main menu"))
|
||||||
.menu_model(&account_menu)
|
.menu_model(&account_menu)
|
||||||
.valign(gtk4::Align::Center)
|
.direction(gtk4::ArrowType::Up)
|
||||||
|
.primary(true)
|
||||||
.build();
|
.build();
|
||||||
account_button.add_css_class("flat");
|
account.add_css_class("flat");
|
||||||
let account = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
|
||||||
account.add_css_class("sidebar-account");
|
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);
|
let footer = gtk4::Box::new(gtk4::Orientation::Vertical, 10);
|
||||||
footer.add_css_class("sidebar-footer");
|
footer.add_css_class("sidebar-footer");
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -132,8 +132,13 @@ impl FileList {
|
|||||||
// and the grid collapses to a single column. Allowing a mid-word
|
// and the grid collapses to a single column. Allowing a mid-word
|
||||||
// break is what keeps the two-line-then-ellipsis budget below
|
// break is what keeps the two-line-then-ellipsis budget below
|
||||||
// enforceable for *every* name rather than only the ones that
|
// 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()
|
let label = gtk4::Label::builder()
|
||||||
|
.attributes(&no_hyphens)
|
||||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||||
.justify(gtk4::Justification::Center)
|
.justify(gtk4::Justification::Center)
|
||||||
.max_width_chars(13)
|
.max_width_chars(13)
|
||||||
|
|||||||
@@ -128,20 +128,34 @@ impl ActionMenu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where a menu opened over `anchor` should live in the widget tree: the
|
/// Where a menu opened over `anchor` should live in the widget tree.
|
||||||
/// outermost grid or list view around it, or `anchor` itself. CSS descendant
|
///
|
||||||
/// selectors reach through a popover to its parent, and themes style the
|
/// Not inside a list item: CSS descendant selectors reach through a popover to
|
||||||
/// insides of list items (`gridview > child box { margin: 12px }` in
|
/// its parent, and themes style the insides of list items (`gridview > child
|
||||||
/// Catppuccin); a menu parented to a tile picks those rules up and puts a wide
|
/// box { margin: 12px }` in Catppuccin); a menu parented to a tile picks those
|
||||||
/// margin around every section. Under the view itself it is no item's child.
|
/// 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: >k4::Widget) -> gtk4::Widget {
|
fn popover_host(anchor: >k4::Widget) -> gtk4::Widget {
|
||||||
let mut host = anchor.clone();
|
let mut start = anchor.clone();
|
||||||
let mut widget = anchor.parent();
|
let mut widget = anchor.parent();
|
||||||
while let Some(w) = widget {
|
while let Some(w) = widget {
|
||||||
widget = w.parent();
|
widget = w.parent();
|
||||||
if w.is::<gtk4::GridView>() || w.is::<gtk4::ListView>() || w.is::<gtk4::ColumnView>() {
|
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,4 +1,5 @@
|
|||||||
pub(crate) mod compat;
|
pub(crate) mod compat;
|
||||||
|
pub(crate) mod crumb;
|
||||||
pub(crate) mod details;
|
pub(crate) mod details;
|
||||||
pub(crate) mod file_list;
|
pub(crate) mod file_list;
|
||||||
pub(crate) mod menu;
|
pub(crate) mod menu;
|
||||||
|
|||||||
@@ -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.
|
/// How an open Share dialog addresses its node.
|
||||||
///
|
///
|
||||||
/// A node the browser is showing has a mountpoint-relative path. A node reached
|
/// 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)
|
.selected(0)
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
let role_wrap = adw::ActionRow::builder().title(gettext("Role")).build();
|
// The role goes with the addresses it applies to, at the end of their row.
|
||||||
role_wrap.add_suffix(&role_drop);
|
role_drop.set_tooltip_text(Some(&gettext("Role")));
|
||||||
|
email_row.add_suffix(&role_drop);
|
||||||
let message_row = adw::EntryRow::builder()
|
let message_row = adw::EntryRow::builder()
|
||||||
.title(gettext("Message (optional)"))
|
.title(gettext("Message (optional)"))
|
||||||
.build();
|
.build();
|
||||||
let invite_btn = gtk4::Button::builder()
|
let invite_btn = group_button(&gettext("Send Invitations"));
|
||||||
.label(gettext("Send Invitations"))
|
|
||||||
.halign(gtk4::Align::End)
|
|
||||||
.margin_top(6)
|
|
||||||
.build();
|
|
||||||
invite_btn.add_css_class("suggested-action");
|
|
||||||
invite_btn.set_sensitive(false);
|
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(&email_row);
|
||||||
invite_group.add(&role_wrap);
|
|
||||||
invite_group.add(&message_row);
|
invite_group.add(&message_row);
|
||||||
invite_group.add(&invite_wrap);
|
invite_group.add(&invite_btn);
|
||||||
|
|
||||||
let people = adw::PreferencesGroup::builder()
|
let people = adw::PreferencesGroup::builder()
|
||||||
.title(gettext("People with access"))
|
.title(gettext("People with access"))
|
||||||
@@ -315,21 +326,18 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
.title(gettext("Public link"))
|
.title(gettext("Public link"))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
// Until the share is read, each section says so rather than standing
|
||||||
content.set_margin_top(12);
|
// empty under its title.
|
||||||
content.set_margin_bottom(12);
|
let people_loading = loading_row();
|
||||||
content.set_margin_start(12);
|
people.add(&people_loading);
|
||||||
content.set_margin_end(12);
|
let link_loading = loading_row();
|
||||||
content.append(&invite_group);
|
link_group.add(&link_loading);
|
||||||
content.append(&people);
|
|
||||||
content.append(&link_group);
|
let content = adw::PreferencesPage::new();
|
||||||
let clamp = adw::Clamp::builder().child(&content).build();
|
content.add(&invite_group);
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
content.add(&people);
|
||||||
.vexpand(true)
|
content.add(&link_group);
|
||||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
toolbar.set_content(Some(&content));
|
||||||
.child(&clamp)
|
|
||||||
.build();
|
|
||||||
toolbar.set_content(Some(&scroll));
|
|
||||||
|
|
||||||
let dialog = adw::Dialog::builder()
|
let dialog = adw::Dialog::builder()
|
||||||
.title(pgettext("verb", "Share"))
|
.title(pgettext("verb", "Share"))
|
||||||
@@ -343,8 +351,8 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
target,
|
target,
|
||||||
people,
|
people,
|
||||||
link_group,
|
link_group,
|
||||||
people_rows: RefCell::new(Vec::new()),
|
people_rows: RefCell::new(vec![people_loading.upcast()]),
|
||||||
link_rows: RefCell::new(Vec::new()),
|
link_rows: RefCell::new(vec![link_loading.upcast()]),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check the addresses as they are typed: a malformed one marks the row as
|
// 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());
|
repaint_share_link(&state, link.as_ref());
|
||||||
}
|
}
|
||||||
Ok(Ok(Response::Error { message, .. })) => {
|
Ok(Ok(Response::Error { message, .. })) => {
|
||||||
|
share_dialog_unread(&state);
|
||||||
toast_error(&state.ui, &gettext("Couldn't load sharing"), &message)
|
toast_error(&state.ui, &gettext("Couldn't load sharing"), &message)
|
||||||
}
|
}
|
||||||
_ => toast_error(
|
_ => {
|
||||||
&state.ui,
|
share_dialog_unread(&state);
|
||||||
&gettext("Couldn't load sharing"),
|
toast_error(
|
||||||
&gettext("The Proton Drive service didn't respond."),
|
&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.
|
/// Rebuild the "People with access" rows from a fresh share listing.
|
||||||
pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntry]) {
|
pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntry]) {
|
||||||
for row in state.people_rows.borrow_mut().drain(..) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
for entry in entries {
|
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 {
|
let subtitle = match entry.kind {
|
||||||
ShareEntryKind::Member => gettext("Member"),
|
ShareEntryKind::Member => String::new(),
|
||||||
ShareEntryKind::ProtonInvite => gettext("Invited (pending)"),
|
ShareEntryKind::ProtonInvite => gettext("Invitation pending"),
|
||||||
ShareEntryKind::ExternalInvite => gettext("Invited (external, pending)"),
|
ShareEntryKind::ExternalInvite => gettext("Invitation pending, no Proton account yet"),
|
||||||
};
|
};
|
||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(&entry.email)
|
.title(&entry.email)
|
||||||
.subtitle(&subtitle)
|
.subtitle(&subtitle)
|
||||||
.build();
|
.build();
|
||||||
|
row.add_prefix(&adw::Avatar::new(32, Some(&entry.email), true));
|
||||||
|
|
||||||
// External invites can't have their role changed; show it read-only.
|
// External invites can't have their role changed; show it read-only.
|
||||||
// Members and Proton invites get a role dropdown.
|
// 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();
|
.build();
|
||||||
let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build();
|
let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build();
|
||||||
expiry_row.add_suffix(&expiry_drop);
|
expiry_row.add_suffix(&expiry_drop);
|
||||||
let create = gtk4::Button::builder()
|
let create = group_button(&gettext("Create Public Link"));
|
||||||
.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();
|
|
||||||
|
|
||||||
// Enter in the password field creates the link.
|
// Enter in the password field creates the link.
|
||||||
let btn = create.clone();
|
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(&role_row);
|
||||||
state.link_group.add(&pw_row);
|
state.link_group.add(&pw_row);
|
||||||
state.link_group.add(&expiry_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(role_row.upcast());
|
||||||
rows.push(pw_row.upcast());
|
rows.push(pw_row.upcast());
|
||||||
rows.push(expiry_row.upcast());
|
rows.push(expiry_row.upcast());
|
||||||
rows.push(create_wrap.upcast());
|
rows.push(create.upcast());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
*state.link_rows.borrow_mut() = rows;
|
*state.link_rows.borrow_mut() = rows;
|
||||||
|
|||||||
+60
-17
@@ -88,8 +88,9 @@ struct DriveState {
|
|||||||
/// Summarise a work snapshot into one menu line, or empty when idle. A single
|
/// 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
|
/// 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
|
/// 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",
|
/// running job speaks for itself — a scan or an index rebuild is still work
|
||||||
/// and the tray saying nothing there reads as "finished".
|
/// 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 {
|
fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
|
||||||
match items {
|
match items {
|
||||||
[] => match jobs.first() {
|
[] => match jobs.first() {
|
||||||
@@ -143,12 +144,12 @@ struct DriveTray {
|
|||||||
fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||||
match send(socket, &Request::Status) {
|
match send(socket, &Request::Status) {
|
||||||
Ok(status @ Response::Status { .. }) => {
|
Ok(status @ Response::Status { .. }) => {
|
||||||
// Same daemon is up, so a cheap follow-up request gives the sync line.
|
// Same daemon is up, so a cheap follow-up request says what moves.
|
||||||
let sync = match send(socket, &Request::GetQueueStatus) {
|
let (items, jobs) = match send(socket, &Request::GetQueueStatus) {
|
||||||
Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs),
|
Ok(Response::Transfers { items, jobs }) => (items, jobs),
|
||||||
_ => String::new(),
|
_ => (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.
|
// Socket answered but with something unexpected — treat as up but odd.
|
||||||
Ok(_) => unexpected_reply(default_mountpoint),
|
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`
|
/// The state a running daemon's [`Response::Status`] describes, with `items`
|
||||||
/// saying what it is moving right now.
|
/// and `jobs` saying what it is working on right now.
|
||||||
fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> DriveState {
|
fn status_state(
|
||||||
|
status: &Response,
|
||||||
|
items: &[TransferItem],
|
||||||
|
jobs: &[JobItem],
|
||||||
|
default_mountpoint: &Path,
|
||||||
|
) -> DriveState {
|
||||||
let Response::Status {
|
let Response::Status {
|
||||||
mountpoint,
|
mountpoint,
|
||||||
pinned,
|
pinned,
|
||||||
@@ -239,7 +245,7 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
|
|||||||
paused,
|
paused,
|
||||||
failing_ops,
|
failing_ops,
|
||||||
online,
|
online,
|
||||||
queued.is_some() || !sync.is_empty(),
|
queued.is_some() || busy(items, jobs),
|
||||||
);
|
);
|
||||||
DriveState {
|
DriveState {
|
||||||
line: match (online, queued) {
|
line: match (online, queued) {
|
||||||
@@ -278,11 +284,19 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
|
|||||||
mounted: true,
|
mounted: true,
|
||||||
paused,
|
paused,
|
||||||
mountpoint: PathBuf::from(mountpoint),
|
mountpoint: PathBuf::from(mountpoint),
|
||||||
sync,
|
sync: sync_line(items, jobs),
|
||||||
failing: failing_ops,
|
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
|
/// 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.
|
/// so first; failures outrank everything else the daemon reports.
|
||||||
fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase {
|
fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase {
|
||||||
@@ -547,11 +561,7 @@ fn main() -> Result<()> {
|
|||||||
heard.take(feed);
|
heard.take(feed);
|
||||||
}
|
}
|
||||||
let st = match &heard.status {
|
let st = match &heard.status {
|
||||||
Some(status) => status_state(
|
Some(status) => status_state(status, &heard.items, &heard.jobs, &default_mountpoint),
|
||||||
status,
|
|
||||||
sync_line(&heard.items, &heard.jobs),
|
|
||||||
&default_mountpoint,
|
|
||||||
),
|
|
||||||
// Lost the daemon: ask once, which describes the sign-in instead.
|
// Lost the daemon: ask once, which describes the sign-in instead.
|
||||||
None => poll_state(&socket, &default_mountpoint),
|
None => poll_state(&socket, &default_mountpoint),
|
||||||
};
|
};
|
||||||
@@ -596,6 +606,39 @@ mod tests {
|
|||||||
assert_eq!(phase_of(false, 0, true, false), Phase::Synced);
|
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]
|
#[test]
|
||||||
fn the_issues_item_counts_in_the_singular_and_plural() {
|
fn the_issues_item_counts_in_the_singular_and_plural() {
|
||||||
assert_eq!(issues_label(1), "1 issue — View");
|
assert_eq!(issues_label(1), "1 issue — View");
|
||||||
|
|||||||
@@ -571,9 +571,13 @@ sequenceDiagram
|
|||||||
- The page may post the token as an object or as a JSON string; both are accepted.
|
- 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
|
- 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
|
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 web view, not the app.
|
||||||
- The CLI has no web view. It fails with a message to sign in once with `pdfs-app`; the stored
|
- When the child fails, the app offers the page in the user's own browser instead, as Proton Mail
|
||||||
session then works for the CLI and the service.
|
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
@@ -81,6 +81,7 @@ original wording.
|
|||||||
|
|
||||||
| Entry | Title | What is left |
|
| 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 |
|
| [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 |
|
| [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 |
|
| [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.
|
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
|
### B198 — Switching to online-only deletes local files that never reached Drive
|
||||||
|
|
||||||
**Status:** Fixed (unverified).
|
**Status:** Fixed (unverified).
|
||||||
@@ -392,29 +419,38 @@ and still silences the report.
|
|||||||
|
|
||||||
### B195 — The app crashes when Proton asks for a CAPTCHA on secureblue
|
### 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
|
**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
|
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
|
`bwrap: Can't get type of source /run/systemd/journal/socket: Permission denied` and
|
||||||
`Failed to fully launch dbus-proxy`.
|
`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.
|
**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
|
**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
|
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`.
|
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
|
The view was in the app's own process, so the whole app went down, and nothing in our code can
|
||||||
catch that.
|
catch that.
|
||||||
|
|
||||||
**Fix.** The view runs in a child, `pdfs-app --human-verification`, that reads the URL on stdin
|
**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
|
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
|
status no longer ends the sign-in: the app offers the page in the user's own browser, as Proton
|
||||||
gives the cause and a one-time workaround. The sandbox is not switched off by the app: users choose
|
Mail Bridge does. The tab cannot post the token back, so the user says when they are done, and the
|
||||||
a hardened system for that isolation.
|
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`,
|
**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,
|
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.
|
and handed back its token.
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### Fixed
|
||||||
- **One unreadable folder stopped a synced folder from syncing at all.** A folder you may not
|
- **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
|
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
|
- **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
|
to `.pdfsignore` and then switching moved it to the Drive trash. Ignoring never deletes now, as
|
||||||
the user guide says.
|
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
|
### Added
|
||||||
- **Move another computer's backup to this one.** **Move to This Computer…** on the Computers page,
|
- **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
|
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
||||||
by a new POSIX compliance suite for the filesystem.
|
by a new POSIX compliance suite for the filesystem.
|
||||||
|
|
||||||
|
[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.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
|
[3.1.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.1
|
||||||
|
|||||||
@@ -69,14 +69,16 @@ pdfs status
|
|||||||
## Signing in
|
## Signing in
|
||||||
|
|
||||||
**"Proton is asking for a CAPTCHA"** — Proton wants human verification, usually on a VPN or an
|
**"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;
|
unfamiliar network. `pdfs login` prints the verification page's address: open it in your browser,
|
||||||
the stored session then works for the CLI too.
|
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
|
**"Verify in Your Browser"** — WebKit, which shows the CAPTCHA page in the app, could not start
|
||||||
start its sandbox. Hardened systems such as secureblue keep its bubblewrap from the journal socket,
|
its sandbox. Hardened systems such as secureblue keep its bubblewrap from the journal socket, and
|
||||||
and the log then shows `bwrap: Can't get type of source /run/systemd/journal/socket: Permission
|
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
|
denied` and `Failed to fully launch dbus-proxy`. Choose **Open Verification Page**, complete the
|
||||||
installed. If they are, sign in once with the sandbox switched off for this one run:
|
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
|
```bash
|
||||||
WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1 pdfs-app
|
WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1 pdfs-app
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
# Build + install: cd packaging && makepkg -fi
|
# Build + install: cd packaging && makepkg -fi
|
||||||
|
|
||||||
pkgname=proton-drive-linux
|
pkgname=proton-drive-linux
|
||||||
pkgver=3.1.2
|
pkgver=3.2.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
+544
-515
File diff suppressed because it is too large.
Load diff
+516
-510
File diff suppressed because it is too large.
Load diff
+544
-515
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user