mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 00:47:00 +01:00
feat(core,stream,meta,app,android,cli): sign in to Proton and sync watch history
A Proton account is now optional alongside public links. Signing in (password, then a second-factor code and a mailbox password where the account has them; a CAPTCHA gate is solved in the Android app) stores one session blob in the OS credential store under `proton-account`, writing back every refreshed token since refresh tokens are single-use. With an account, folders of the viewer's own Drive — My Files, other devices, shared with me — can be added as shares through the same `ShareClient` surface as a public link. Watch history syncs through a `proton-stream` device in that Drive: one `watch-history/<installation>.json` per installation, so no two devices ever write the same file. Each sync reads the others and merges per episode, newer `updated_at` wins, with a deterministic tie-break. It runs at launch, after sign-in, whenever the player closes and every five minutes. A session that can no longer refresh signs the app out instead of failing on every launch. The CLI gains `login`, `logout`, `account`, `browse` and `sync`. Also in this change: - Android offline downloads no longer look stuck (B64): two transfer at once, through a copy stream with no read-ahead, with a smoothed transfer rate. - Choosing a metadata match by hand now enriches the title too, instead of keeping the old match's episodes and artwork. The catalog schema is unchanged.
This commit is contained in:
1 parent
6cdab2fde1
commit
0b2813cd42
37 files changed
+4111
-159
No files matched your search
@@ -20,6 +20,8 @@ tracing.workspace = true
|
||||
directories.workspace = true
|
||||
rusqlite.workspace = true
|
||||
sha2.workspace = true
|
||||
parking_lot.workspace = true
|
||||
getrandom.workspace = true
|
||||
|
||||
# The credential store, per platform. Without the target's feature `keyring`
|
||||
# compiles fine and silently resolves to its in-memory `mock` store, which
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
//! Signing in to a Proton account, and keeping the session.
|
||||
//!
|
||||
//! The rest of the app works without one — public links need no account. A
|
||||
//! signed-in account adds two things: folders of the viewer's own Drive (My
|
||||
//! Files, other devices, what was shared with them) as library shares, and
|
||||
//! watch history kept in their Drive so every device resumes where another
|
||||
//! left off ([`crate::sync`]).
|
||||
//!
|
||||
//! ## What is kept, and where
|
||||
//!
|
||||
//! Following `proton-drive-linux`: one JSON blob in the [`SecretStore`] holding
|
||||
//! the session tokens, the mailbox password and the account's key salts. The
|
||||
//! mailbox password is what unlocks the key chain, so without it a resumed
|
||||
//! session could list nothing. The key salts are kept because
|
||||
//! `core/v4/keys/salts` needs a scope only a password sign-in grants: after the
|
||||
//! first token refresh it answers 403, and a session resumed without them
|
||||
//! could never unlock its keys again.
|
||||
//!
|
||||
//! Proton refresh tokens are single-use, so every refresh is written back
|
||||
//! (see [`persist_refreshed_tokens`]). A refresh that is not leaves the store
|
||||
//! holding a dead token and the next launch signed out.
|
||||
//!
|
||||
//! ## Signing in is several steps
|
||||
//!
|
||||
//! Password, then maybe a second-factor code, then — on an account with a
|
||||
//! separate mailbox password — that. Each front end asks for them in its own
|
||||
//! way, so [`AccountStore::sign_in`] returns a [`SignIn`] saying what it needs
|
||||
//! next and holding the half-made session until it is given.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use proton_drive_rs::{KeySalt, NodeKind, ProtonDriveClient};
|
||||
use proton_sdk::api::HumanVerificationCredential;
|
||||
use proton_sdk::error::ProtonError;
|
||||
use proton_sdk::ids::NodeUid;
|
||||
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::shares::{SecretStore, client_configuration};
|
||||
|
||||
/// The secret-store key the session is kept under. Share secrets are keyed
|
||||
/// `share-…`, so the two cannot collide.
|
||||
const SESSION_KEY: &str = "proton-account";
|
||||
|
||||
/// Blocks in flight for streaming from the account, as for a public link —
|
||||
/// see `ShareStore::open`.
|
||||
const MAX_INFLIGHT_BLOCKS: usize = 48;
|
||||
|
||||
/// Everything needed to resume the session unattended.
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
struct StoredSession {
|
||||
session_id: String,
|
||||
username: String,
|
||||
user_id: String,
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
scopes: Vec<String>,
|
||||
/// `1` single password, `2` separate mailbox password — Proton's values.
|
||||
password_mode: u8,
|
||||
mailbox_password: String,
|
||||
key_salts: Vec<KeySalt>,
|
||||
}
|
||||
|
||||
impl StoredSession {
|
||||
fn capture(
|
||||
session: &ProtonApiSession,
|
||||
tokens: proton_sdk::http::Tokens,
|
||||
mailbox_password: &str,
|
||||
key_salts: Vec<KeySalt>,
|
||||
) -> Self {
|
||||
Self {
|
||||
session_id: session.session_id().as_str().to_owned(),
|
||||
username: session.username().to_owned(),
|
||||
user_id: session.user_id().as_str().to_owned(),
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
scopes: session.scopes().to_vec(),
|
||||
password_mode: match session.password_mode() {
|
||||
PasswordMode::Single => 1,
|
||||
PasswordMode::Dual => 2,
|
||||
},
|
||||
mailbox_password: mailbox_password.to_owned(),
|
||||
key_salts,
|
||||
}
|
||||
}
|
||||
|
||||
fn resume_parameters(&self) -> ResumeParameters {
|
||||
ResumeParameters {
|
||||
session_id: self.session_id.clone().into(),
|
||||
username: self.username.clone(),
|
||||
user_id: self.user_id.clone().into(),
|
||||
access_token: self.access_token.clone(),
|
||||
refresh_token: self.refresh_token.clone(),
|
||||
scopes: self.scopes.clone(),
|
||||
is_waiting_for_second_factor_code: false,
|
||||
password_mode: match self.password_mode {
|
||||
1 => PasswordMode::Single,
|
||||
_ => PasswordMode::Dual,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A signed-in account, ready for Drive work.
|
||||
///
|
||||
/// Cheap to clone; clones share one session and one set of caches.
|
||||
#[derive(Clone)]
|
||||
pub struct Account {
|
||||
session: ProtonApiSession,
|
||||
drive: ProtonDriveClient,
|
||||
}
|
||||
|
||||
impl Account {
|
||||
/// The address the viewer signed in with.
|
||||
pub fn username(&self) -> &str {
|
||||
self.session.username()
|
||||
}
|
||||
|
||||
/// The Drive client the account's folders and watch history go through.
|
||||
pub fn drive(&self) -> &ProtonDriveClient {
|
||||
&self.drive
|
||||
}
|
||||
|
||||
/// Where a library folder can come from: My Files, each device, and each
|
||||
/// folder someone shared with the viewer.
|
||||
///
|
||||
/// The device this app keeps watch history in is left out — it holds
|
||||
/// nothing to watch. A device or share whose name will not decrypt is
|
||||
/// still listed, under a placeholder, rather than hidden: it is the
|
||||
/// viewer's, and they can still open it.
|
||||
pub async fn places(&self) -> Result<Vec<DrivePlace>> {
|
||||
let mut places = Vec::new();
|
||||
let my_files = self.drive.get_my_files_folder().await?;
|
||||
places.push(DrivePlace {
|
||||
kind: PlaceKind::MyFiles,
|
||||
name: "My files".to_owned(),
|
||||
uid: my_files.uid,
|
||||
});
|
||||
|
||||
let mut devices = self.drive.enumerate_devices().await?;
|
||||
devices.sort_by_key(|device| device.creation_time);
|
||||
for device in devices {
|
||||
let name = device.name.unwrap_or_else(|_| "Unnamed device".to_owned());
|
||||
if name == crate::sync::DEVICE_NAME {
|
||||
continue;
|
||||
}
|
||||
places.push(DrivePlace {
|
||||
kind: PlaceKind::Device,
|
||||
name,
|
||||
uid: device.root_folder_uid,
|
||||
});
|
||||
}
|
||||
|
||||
let shared: Vec<NodeUid> = self
|
||||
.drive
|
||||
.enumerate_shared_with_me()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|item| item.uid)
|
||||
.collect();
|
||||
if !shared.is_empty() {
|
||||
let mut nodes = self.drive.enumerate_nodes(&shared).await?;
|
||||
nodes.retain(|node| node.is_folder() && !node.trashed);
|
||||
nodes.sort_by_key(|node| node.name.to_lowercase());
|
||||
places.extend(nodes.into_iter().map(|node| DrivePlace {
|
||||
kind: PlaceKind::SharedWithMe,
|
||||
name: node.name,
|
||||
uid: node.uid,
|
||||
}));
|
||||
}
|
||||
Ok(places)
|
||||
}
|
||||
|
||||
/// What one folder of the account holds: folders first, then files, each
|
||||
/// by name. Trashed nodes are left out.
|
||||
pub async fn folder_children(&self, folder: &NodeUid) -> Result<Vec<DriveEntry>> {
|
||||
let uids = self
|
||||
.drive
|
||||
.enumerate_folder_children_node_uids(folder)
|
||||
.await?;
|
||||
if uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut entries: Vec<DriveEntry> = self
|
||||
.drive
|
||||
.enumerate_nodes(&uids)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|node| !node.trashed)
|
||||
.map(|node| {
|
||||
let (size, media_type) = match &node.kind {
|
||||
NodeKind::File {
|
||||
media_type,
|
||||
claimed_size,
|
||||
..
|
||||
} => (*claimed_size, Some(media_type.clone())),
|
||||
_ => (None, None),
|
||||
};
|
||||
DriveEntry {
|
||||
is_folder: node.is_folder(),
|
||||
uid: node.uid,
|
||||
name: node.name,
|
||||
size,
|
||||
media_type,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
entries.sort_by(|a, b| {
|
||||
b.is_folder
|
||||
.cmp(&a.is_folder)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// End the session on Proton's side. Best-effort: a session that is
|
||||
/// already dead has nothing to end.
|
||||
async fn end(&self) {
|
||||
if let Err(error) = self.session.end().await {
|
||||
tracing::debug!(%error, "ending the Proton session failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which kind of place a [`DrivePlace`] is.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PlaceKind {
|
||||
MyFiles,
|
||||
Device,
|
||||
SharedWithMe,
|
||||
}
|
||||
|
||||
/// A top-level place in the account's Drive.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DrivePlace {
|
||||
pub kind: PlaceKind,
|
||||
pub name: String,
|
||||
pub uid: NodeUid,
|
||||
}
|
||||
|
||||
/// One node of a folder listing.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DriveEntry {
|
||||
pub uid: NodeUid,
|
||||
pub name: String,
|
||||
pub is_folder: bool,
|
||||
/// Plaintext size, when the uploader recorded one.
|
||||
pub size: Option<i64>,
|
||||
pub media_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Where a sign-in stands.
|
||||
pub enum SignIn {
|
||||
/// Signed in; the session is stored.
|
||||
Done(Account),
|
||||
/// The account has two-factor authentication: ask for a code and pass it to
|
||||
/// [`PendingSignIn::second_factor`].
|
||||
SecondFactor(PendingSignIn),
|
||||
/// The account has a separate mailbox password: ask for it and pass it to
|
||||
/// [`PendingSignIn::mailbox_password`].
|
||||
MailboxPassword(PendingSignIn),
|
||||
}
|
||||
|
||||
/// A sign-in waiting on the viewer.
|
||||
pub struct PendingSignIn {
|
||||
secrets: Arc<dyn SecretStore>,
|
||||
session: ProtonApiSession,
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl PendingSignIn {
|
||||
/// Submit a second-factor code.
|
||||
///
|
||||
/// A wrong code fails here and leaves nothing behind; the front end starts
|
||||
/// the sign-in over, because Proton does not accept a second try on the
|
||||
/// same half-made session.
|
||||
pub async fn second_factor(mut self, code: &str) -> Result<SignIn> {
|
||||
self.session
|
||||
.apply_second_factor_code(code.trim())
|
||||
.await
|
||||
.map_err(|error| match error {
|
||||
ProtonError::Api(api) if (400..500).contains(&api.http_status) => {
|
||||
Error::SignIn("that code was not accepted".to_owned())
|
||||
}
|
||||
other => other.into(),
|
||||
})?;
|
||||
self.after_second_factor().await
|
||||
}
|
||||
|
||||
/// Submit the mailbox password, on an account that has one.
|
||||
///
|
||||
/// By reference, unlike the code: a mistyped mailbox password is checked
|
||||
/// on this side, the session survives it, and the viewer can try again
|
||||
/// without starting over.
|
||||
pub async fn mailbox_password(&self, mailbox_password: &str) -> Result<Account> {
|
||||
finish(
|
||||
Arc::clone(&self.secrets),
|
||||
self.session.clone(),
|
||||
mailbox_password,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn after_second_factor(self) -> Result<SignIn> {
|
||||
if self.session.password_mode() == PasswordMode::Dual {
|
||||
return Ok(SignIn::MailboxPassword(self));
|
||||
}
|
||||
let password = self.password.clone();
|
||||
finish(self.secrets, self.session, &password)
|
||||
.await
|
||||
.map(SignIn::Done)
|
||||
}
|
||||
}
|
||||
|
||||
/// The signed-in account, if any, and signing in and out.
|
||||
#[derive(Clone)]
|
||||
pub struct AccountStore {
|
||||
secrets: Arc<dyn SecretStore>,
|
||||
}
|
||||
|
||||
impl AccountStore {
|
||||
pub fn new(secrets: Arc<dyn SecretStore>) -> Self {
|
||||
Self { secrets }
|
||||
}
|
||||
|
||||
/// The address of the stored account, without touching the network.
|
||||
pub fn username(&self) -> Result<Option<String>> {
|
||||
Ok(self.load()?.map(|stored| stored.username))
|
||||
}
|
||||
|
||||
/// Start signing in.
|
||||
///
|
||||
/// `verification` is the token from a solved CAPTCHA, after a first try
|
||||
/// failed with [`Error::HumanVerification`]; the sign-in starts over with
|
||||
/// it, because the gated attempt's SRP handshake is spent.
|
||||
pub async fn sign_in(
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
verification: Option<HumanVerificationCredential>,
|
||||
) -> Result<SignIn> {
|
||||
let session = ProtonApiSession::begin_verified(
|
||||
client_configuration(),
|
||||
username.trim(),
|
||||
password.as_bytes(),
|
||||
verification.as_ref(),
|
||||
)
|
||||
.await
|
||||
.map_err(classify_sign_in_failure)?;
|
||||
let pending = PendingSignIn {
|
||||
secrets: Arc::clone(&self.secrets),
|
||||
session,
|
||||
password: password.to_owned(),
|
||||
};
|
||||
if pending.session.is_waiting_for_second_factor() {
|
||||
return Ok(SignIn::SecondFactor(pending));
|
||||
}
|
||||
pending.after_second_factor().await
|
||||
}
|
||||
|
||||
/// The stored account, resumed, or `None` when nobody is signed in.
|
||||
///
|
||||
/// Makes no request: a session whose refresh token has died is found out
|
||||
/// by the first call that uses it, as [`Error::is_signed_out`].
|
||||
pub fn resume(&self) -> Result<Option<Account>> {
|
||||
let Some(stored) = self.load()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let session = ProtonApiSession::resume(client_configuration(), stored.resume_parameters())?;
|
||||
persist_refreshed_tokens(
|
||||
&self.secrets,
|
||||
&session,
|
||||
stored.mailbox_password.clone(),
|
||||
stored.key_salts.clone(),
|
||||
);
|
||||
let drive = ProtonDriveClient::with_key_salts(
|
||||
&session,
|
||||
stored.mailbox_password.into_bytes(),
|
||||
stored.key_salts,
|
||||
);
|
||||
Ok(Some(account(session, drive)))
|
||||
}
|
||||
|
||||
/// Forget the account: end its session and delete what was stored.
|
||||
///
|
||||
/// The stored session goes even when ending it fails — signing out on a
|
||||
/// plane must still sign out.
|
||||
pub async fn sign_out(&self, account: Option<&Account>) -> Result<()> {
|
||||
if let Some(account) = account {
|
||||
account.end().await;
|
||||
}
|
||||
self.secrets.delete(SESSION_KEY)
|
||||
}
|
||||
|
||||
fn load(&self) -> Result<Option<StoredSession>> {
|
||||
let Some(encoded) = self.secrets.get(SESSION_KEY)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
serde_json::from_str(&encoded)
|
||||
.map(Some)
|
||||
.map_err(|e| Error::Config(format!("the stored Proton session is unreadable: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Unlock the key chain with `mailbox_password`, store the session, and hand
|
||||
/// back the account.
|
||||
///
|
||||
/// The key salts are read here, while the token still has the scope that
|
||||
/// endpoint needs. Loading the addresses is what proves the mailbox password:
|
||||
/// it unlocks the user keys, and a wrong password unlocks none of them.
|
||||
async fn finish(
|
||||
secrets: Arc<dyn SecretStore>,
|
||||
session: ProtonApiSession,
|
||||
mailbox_password: &str,
|
||||
) -> Result<Account> {
|
||||
let probe = ProtonDriveClient::new(&session, mailbox_password.as_bytes().to_vec());
|
||||
let key_salts = probe.account().key_salts().await?;
|
||||
probe
|
||||
.account()
|
||||
.addresses()
|
||||
.await
|
||||
.map_err(|error| match error {
|
||||
ProtonError::InvalidOperation(_) => {
|
||||
Error::SignIn("that mailbox password did not unlock the account".to_owned())
|
||||
}
|
||||
other => other.into(),
|
||||
})?;
|
||||
|
||||
let stored = StoredSession::capture(
|
||||
&session,
|
||||
session.current_tokens().await,
|
||||
mailbox_password,
|
||||
key_salts.clone(),
|
||||
);
|
||||
let encoded = serde_json::to_string(&stored)
|
||||
.map_err(|e| Error::Config(format!("serialize the Proton session: {e}")))?;
|
||||
secrets.set(SESSION_KEY, &encoded)?;
|
||||
persist_refreshed_tokens(
|
||||
&secrets,
|
||||
&session,
|
||||
mailbox_password.to_owned(),
|
||||
key_salts.clone(),
|
||||
);
|
||||
|
||||
let drive = ProtonDriveClient::with_key_salts(
|
||||
&session,
|
||||
mailbox_password.as_bytes().to_vec(),
|
||||
key_salts,
|
||||
);
|
||||
Ok(account(session, drive))
|
||||
}
|
||||
|
||||
fn account(session: ProtonApiSession, drive: ProtonDriveClient) -> Account {
|
||||
let drive = drive
|
||||
.with_max_inflight_blocks(MAX_INFLIGHT_BLOCKS)
|
||||
// A watch-history file is one small block; the single-request upload
|
||||
// is one failure point where the draft dance is three.
|
||||
.with_small_file_upload(true);
|
||||
Account { session, drive }
|
||||
}
|
||||
|
||||
/// Write every refreshed token pair back to the secret store.
|
||||
///
|
||||
/// Refresh tokens are single-use: once the HTTP client has spent the stored
|
||||
/// one, only the pair it got back can resume the session.
|
||||
fn persist_refreshed_tokens(
|
||||
secrets: &Arc<dyn SecretStore>,
|
||||
session: &ProtonApiSession,
|
||||
mailbox_password: String,
|
||||
key_salts: Vec<KeySalt>,
|
||||
) {
|
||||
let secrets = Arc::clone(secrets);
|
||||
let template = session.clone();
|
||||
session.http().set_on_tokens_refreshed(move |tokens| {
|
||||
let stored =
|
||||
StoredSession::capture(&template, tokens, &mailbox_password, key_salts.clone());
|
||||
let written = serde_json::to_string(&stored)
|
||||
.map_err(|e| Error::Config(e.to_string()))
|
||||
.and_then(|encoded| secrets.set(SESSION_KEY, &encoded));
|
||||
if let Err(error) = written {
|
||||
tracing::warn!(%error, "the refreshed Proton session could not be stored");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Lift a CAPTCHA gate and a wrong password out of the generic API error.
|
||||
///
|
||||
/// Only a gate that can be solved in an app becomes
|
||||
/// [`Error::HumanVerification`]; one that wants an e-mail or SMS code is a plain
|
||||
/// failure, which reads better than an empty verification page.
|
||||
fn classify_sign_in_failure(error: ProtonError) -> Error {
|
||||
if let ProtonError::Api(api) = &error {
|
||||
if let Some(challenge) = api.human_verification()
|
||||
&& challenge.supports_captcha()
|
||||
{
|
||||
return Error::HumanVerification(Box::new(challenge));
|
||||
}
|
||||
if api.http_status == 422 && !api.is_human_verification_required() {
|
||||
return Error::SignIn(if api.message.is_empty() {
|
||||
"Proton did not accept that address and password".to_owned()
|
||||
} else {
|
||||
api.message.clone()
|
||||
});
|
||||
}
|
||||
}
|
||||
error.into()
|
||||
}
|
||||
|
||||
/// The token out of a message posted by Proton's verification page, or `None`
|
||||
/// for anything that is not a completion.
|
||||
///
|
||||
/// The page posts plenty besides — resize requests, readiness pings — and
|
||||
/// taking an unrecognised message for success would retry the sign-in with a
|
||||
/// garbage token. Both spellings of the success type have shipped, and some
|
||||
/// hosts hand the message over stringified twice.
|
||||
pub fn verification_token(raw: &str) -> Option<String> {
|
||||
let mut value: serde_json::Value = serde_json::from_str(raw).ok()?;
|
||||
if let Some(inner) = value.as_str()
|
||||
&& let Ok(parsed) = serde_json::from_str(inner)
|
||||
{
|
||||
value = parsed;
|
||||
}
|
||||
let kind = value.get("type")?.as_str()?;
|
||||
if !matches!(
|
||||
kind,
|
||||
"HUMAN_VERIFICATION_SUCCESS" | "human_verification_success"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let token = value.get("payload")?.get("token")?.as_str()?;
|
||||
(!token.is_empty()).then(|| token.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
struct MemorySecrets(Mutex<HashMap<String, String>>);
|
||||
|
||||
impl SecretStore for MemorySecrets {
|
||||
fn set(&self, key: &str, value: &str) -> Result<()> {
|
||||
self.0.lock().insert(key.to_owned(), value.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
fn get(&self, key: &str) -> Result<Option<String>> {
|
||||
Ok(self.0.lock().get(key).cloned())
|
||||
}
|
||||
fn delete(&self, key: &str) -> Result<()> {
|
||||
self.0.lock().remove(key);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn stored() -> StoredSession {
|
||||
StoredSession {
|
||||
session_id: "session".to_owned(),
|
||||
username: "viewer@proton.me".to_owned(),
|
||||
user_id: "user".to_owned(),
|
||||
access_token: "access".to_owned(),
|
||||
refresh_token: "refresh".to_owned(),
|
||||
scopes: vec!["drive".to_owned()],
|
||||
password_mode: 2,
|
||||
mailbox_password: "mailbox".to_owned(),
|
||||
key_salts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nobody_is_signed_in_until_a_session_is_stored() {
|
||||
let store = AccountStore::new(Arc::new(MemorySecrets::default()));
|
||||
assert_eq!(store.username().expect("read"), None);
|
||||
assert!(store.resume().expect("resume").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_session_resumes_as_its_account_without_a_request() {
|
||||
let secrets = Arc::new(MemorySecrets::default());
|
||||
secrets
|
||||
.set(SESSION_KEY, &serde_json::to_string(&stored()).unwrap())
|
||||
.unwrap();
|
||||
let store = AccountStore::new(secrets);
|
||||
assert_eq!(
|
||||
store.username().expect("read").as_deref(),
|
||||
Some("viewer@proton.me")
|
||||
);
|
||||
let account = store.resume().expect("resume").expect("signed in");
|
||||
assert_eq!(account.username(), "viewer@proton.me");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn signing_out_forgets_the_stored_session() {
|
||||
let secrets = Arc::new(MemorySecrets::default());
|
||||
secrets
|
||||
.set(SESSION_KEY, &serde_json::to_string(&stored()).unwrap())
|
||||
.unwrap();
|
||||
let store = AccountStore::new(secrets);
|
||||
store.sign_out(None).await.expect("sign out");
|
||||
assert_eq!(store.username().expect("read"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_session_keeps_its_password_mode() {
|
||||
let params = stored().resume_parameters();
|
||||
assert_eq!(params.password_mode, PasswordMode::Dual);
|
||||
assert!(!params.is_waiting_for_second_factor_code);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_verification_completion_yields_its_token() {
|
||||
let raw =
|
||||
r#"{"type":"HUMAN_VERIFICATION_SUCCESS","payload":{"token":"tok-1","type":"captcha"}}"#;
|
||||
assert_eq!(verification_token(raw).as_deref(), Some("tok-1"));
|
||||
let twice = r#""{\"type\":\"HUMAN_VERIFICATION_SUCCESS\",\"payload\":{\"token\":\"tok-1\",\"type\":\"captcha\"}}""#;
|
||||
assert_eq!(verification_token(twice).as_deref(), Some("tok-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_that_are_not_a_verification_completion_are_ignored() {
|
||||
for raw in [
|
||||
r#"{"type":"resize","payload":{"height":400}}"#,
|
||||
r#"{"type":"HUMAN_VERIFICATION_SUCCESS"}"#,
|
||||
r#"{"type":"HUMAN_VERIFICATION_SUCCESS","payload":{"token":""}}"#,
|
||||
"not json",
|
||||
"",
|
||||
] {
|
||||
assert!(verification_token(raw).is_none(), "accepted: {raw}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -721,6 +721,46 @@ impl Catalog {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Take each of `states` that is newer than what is recorded, in one
|
||||
/// transaction, returning how many were taken.
|
||||
///
|
||||
/// Newer is a later `updated_at`. A tie is broken by the state itself —
|
||||
/// watched over not, then the further position — rather than by who is
|
||||
/// asking, so two devices holding the same tie settle on the same answer
|
||||
/// instead of each keeping its own forever. See [`crate::sync`].
|
||||
pub fn merge_watch_states(&mut self, states: &[(String, String, WatchState)]) -> Result<usize> {
|
||||
let tx = self.conn.transaction()?;
|
||||
let mut taken = 0;
|
||||
{
|
||||
let mut upsert = tx.prepare_cached(
|
||||
"INSERT INTO watch_state (share_id, link_id, position_secs, duration_secs, watched, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT (share_id, link_id) DO UPDATE SET
|
||||
position_secs = excluded.position_secs,
|
||||
duration_secs = excluded.duration_secs,
|
||||
watched = excluded.watched,
|
||||
updated_at = excluded.updated_at
|
||||
WHERE excluded.updated_at > watch_state.updated_at
|
||||
OR (excluded.updated_at = watch_state.updated_at
|
||||
AND (excluded.watched > watch_state.watched
|
||||
OR (excluded.watched = watch_state.watched
|
||||
AND excluded.position_secs > watch_state.position_secs)))",
|
||||
)?;
|
||||
for (share_id, link_id, state) in states {
|
||||
taken += upsert.execute(params![
|
||||
share_id,
|
||||
link_id,
|
||||
state.position_secs,
|
||||
state.duration_secs,
|
||||
state.watched as i64,
|
||||
state.updated_at,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(taken)
|
||||
}
|
||||
|
||||
/// Where playback left off, if it ever started.
|
||||
pub fn watch_state(&self, share_id: &str, link_id: &str) -> Result<Option<WatchState>> {
|
||||
let state = self
|
||||
@@ -2390,6 +2430,69 @@ mod tests {
|
||||
assert_eq!(rows[0].link_id, "film");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merging_takes_only_states_newer_than_the_recorded_ones() {
|
||||
let mut catalog = Catalog::in_memory().expect("open");
|
||||
let at = |position_secs: f64, updated_at: i64| WatchState {
|
||||
position_secs,
|
||||
duration_secs: Some(1440.0),
|
||||
watched: false,
|
||||
updated_at,
|
||||
};
|
||||
catalog
|
||||
.set_watch_state("s1", "kept", &at(100.0, 50))
|
||||
.unwrap();
|
||||
catalog
|
||||
.set_watch_state("s1", "taken", &at(100.0, 50))
|
||||
.unwrap();
|
||||
let taken = catalog
|
||||
.merge_watch_states(&[
|
||||
("s1".to_owned(), "kept".to_owned(), at(900.0, 40)),
|
||||
("s1".to_owned(), "taken".to_owned(), at(900.0, 60)),
|
||||
("s1".to_owned(), "new".to_owned(), at(5.0, 10)),
|
||||
])
|
||||
.expect("merge");
|
||||
assert_eq!(taken, 2);
|
||||
assert_eq!(
|
||||
catalog.watch_state("s1", "kept").unwrap(),
|
||||
Some(at(100.0, 50))
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.watch_state("s1", "taken").unwrap(),
|
||||
Some(at(900.0, 60))
|
||||
);
|
||||
assert_eq!(catalog.watch_state("s1", "new").unwrap(), Some(at(5.0, 10)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_merge_tie_settles_the_same_way_whichever_side_holds_it() {
|
||||
let state = |position_secs: f64, watched: bool| WatchState {
|
||||
position_secs,
|
||||
duration_secs: Some(1440.0),
|
||||
watched,
|
||||
updated_at: 70,
|
||||
};
|
||||
for (local, remote) in [
|
||||
(state(300.0, false), state(1440.0, true)),
|
||||
(state(1440.0, true), state(300.0, false)),
|
||||
(state(300.0, false), state(600.0, false)),
|
||||
(state(600.0, false), state(300.0, false)),
|
||||
] {
|
||||
let mut catalog = Catalog::in_memory().expect("open");
|
||||
catalog.set_watch_state("s1", "a", &local).unwrap();
|
||||
catalog
|
||||
.merge_watch_states(&[("s1".to_owned(), "a".to_owned(), remote)])
|
||||
.unwrap();
|
||||
let winner =
|
||||
if (local.watched, local.position_secs) > (remote.watched, remote.position_secs) {
|
||||
local
|
||||
} else {
|
||||
remote
|
||||
};
|
||||
assert_eq!(catalog.watch_state("s1", "a").unwrap(), Some(winner));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unwatched_file_has_no_watch_state() {
|
||||
let catalog = Catalog::in_memory().expect("open");
|
||||
|
||||
@@ -88,6 +88,12 @@ impl AppDirs {
|
||||
self.config.join("shares.json")
|
||||
}
|
||||
|
||||
/// This installation's identity for watch-history sync. See
|
||||
/// [`crate::sync`].
|
||||
pub fn sync_file(&self) -> PathBuf {
|
||||
self.config.join("sync.json")
|
||||
}
|
||||
|
||||
/// The catalog database.
|
||||
pub fn catalog_db(&self) -> PathBuf {
|
||||
self.data.join("catalog.db")
|
||||
|
||||
@@ -30,6 +30,37 @@ pub enum Error {
|
||||
/// The caller asked for something that does not exist or does not apply.
|
||||
#[error("{0}")]
|
||||
NotFound(String),
|
||||
|
||||
/// Proton gated a sign-in behind a CAPTCHA. The front end presents
|
||||
/// [`HumanVerification::verification_url`](proton_sdk::api::HumanVerification::verification_url)
|
||||
/// and signs in again with the token it yields.
|
||||
#[error("Proton wants to confirm you are human before signing in")]
|
||||
HumanVerification(Box<proton_sdk::api::HumanVerification>),
|
||||
|
||||
/// No account is signed in, or its session can no longer be refreshed and
|
||||
/// the viewer has to sign in again.
|
||||
#[error("{0}")]
|
||||
SignedOut(String),
|
||||
|
||||
/// Signing in failed for a reason the viewer can act on, such as a wrong
|
||||
/// mailbox password.
|
||||
#[error("{0}")]
|
||||
SignIn(String),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Whether this is the account's session having ended: the refresh token
|
||||
/// was revoked or expired, or the token lacks a scope only a password
|
||||
/// sign-in grants. Nothing but signing in again fixes it.
|
||||
pub fn is_signed_out(&self) -> bool {
|
||||
match self {
|
||||
Self::SignedOut(_) => true,
|
||||
Self::Drive(proton_sdk::error::ProtonError::Api(api)) => {
|
||||
api.is_invalid_refresh_token() || api.is_insufficient_scope()
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! how a share is opened and how its contents are modelled.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod account;
|
||||
pub mod appearance;
|
||||
pub mod browse;
|
||||
pub mod catalog;
|
||||
@@ -18,11 +19,13 @@ pub mod naming;
|
||||
pub mod prefs;
|
||||
pub mod shares;
|
||||
pub mod shelves;
|
||||
pub mod sync;
|
||||
|
||||
pub use account::{Account, AccountStore};
|
||||
pub use error::{Error, Result};
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub use shares::KeyringSecretStore;
|
||||
pub use shares::{SecretStore, Share, ShareStore, SharedLibrary};
|
||||
pub use shares::{SecretStore, Share, ShareClient, ShareStore, SharedLibrary};
|
||||
|
||||
pub use proton_drive_rs;
|
||||
pub use proton_sdk;
|
||||
+213
-18
@@ -14,15 +14,28 @@
|
||||
//! A library is not one folder. [`SharedLibrary`] opens every configured share
|
||||
//! and merges them into one catalog, so the app has a single browsable view
|
||||
//! regardless of how many links it was given.
|
||||
//!
|
||||
//! ## Folders from a signed-in account
|
||||
//!
|
||||
//! A share can also be a folder of the viewer's own Drive — My Files, another
|
||||
//! device, or something shared with them — opened through the account signed
|
||||
//! in by [`crate::account`] instead of a link. It has no secrets of its own:
|
||||
//! the account session is the credential, and without it such a share simply
|
||||
//! fails to open, the same as a revoked link. [`ShareClient`] puts both kinds
|
||||
//! behind one surface so the crawl, the stream layer and the thumbnail path
|
||||
//! do not care which one they hold.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use proton_drive_rs::{Node, ProtonDrivePublicLinkClient};
|
||||
use proton_drive_rs::{
|
||||
Node, ProtonDriveClient, ProtonDrivePublicLinkClient, RevisionReader, ThumbnailType,
|
||||
};
|
||||
use proton_sdk::config::ProtonClientConfiguration;
|
||||
use proton_sdk::ids::NodeUid;
|
||||
use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::account::Account;
|
||||
use crate::config::{AppDirs, read_json, write_json};
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
@@ -92,6 +105,47 @@ pub struct Share {
|
||||
/// rather than after it fails.
|
||||
#[serde(default)]
|
||||
pub has_custom_password: bool,
|
||||
/// Set when this share is a folder of the signed-in account rather than a
|
||||
/// public link; `token` is then empty and there are no secrets.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub folder: Option<AccountFolder>,
|
||||
}
|
||||
|
||||
/// A folder of the signed-in account, kept as a library share.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AccountFolder {
|
||||
pub volume_id: String,
|
||||
pub link_id: String,
|
||||
}
|
||||
|
||||
impl AccountFolder {
|
||||
pub fn uid(&self) -> NodeUid {
|
||||
NodeUid::new(
|
||||
VolumeId::from(self.volume_id.clone()),
|
||||
LinkId::from(self.link_id.clone()),
|
||||
)
|
||||
}
|
||||
|
||||
/// The share id this folder is kept under.
|
||||
///
|
||||
/// Derived, like a link's `share-{token}`, so the same folder added on two
|
||||
/// devices is the same share — which is what lets watch history recorded
|
||||
/// against it on one device land on the other. Hashed rather than spliced
|
||||
/// in because Proton ids are base64 and may carry characters a catalog key
|
||||
/// is better off without.
|
||||
pub fn share_id(&self) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(self.volume_id.as_bytes());
|
||||
hash.update([0]);
|
||||
hash.update(self.link_id.as_bytes());
|
||||
let digest = hash.finalize();
|
||||
let hex: String = digest[..12]
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
format!("drive-{hex}")
|
||||
}
|
||||
}
|
||||
|
||||
/// The secrets for one share, as held in the credential store.
|
||||
@@ -154,6 +208,31 @@ impl ShareStore {
|
||||
name: name.to_string(),
|
||||
token,
|
||||
has_custom_password: secrets.custom_password.is_some(),
|
||||
folder: None,
|
||||
};
|
||||
shares.push(share.clone());
|
||||
write_json(&self.dirs.shares_file(), &shares)?;
|
||||
Ok(share)
|
||||
}
|
||||
|
||||
/// Record a folder of the signed-in account as a share.
|
||||
///
|
||||
/// Nothing goes in the credential store: the folder opens through the
|
||||
/// account's session, which [`crate::account::AccountStore`] keeps.
|
||||
pub fn add_folder(&self, name: &str, folder: AccountFolder) -> Result<Share> {
|
||||
let id = folder.share_id();
|
||||
let mut shares = self.list()?;
|
||||
if shares.iter().any(|share| share.id == id) {
|
||||
return Err(Error::Config(
|
||||
"that folder is already in the library".to_owned(),
|
||||
));
|
||||
}
|
||||
let share = Share {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
token: String::new(),
|
||||
has_custom_password: false,
|
||||
folder: Some(folder),
|
||||
};
|
||||
shares.push(share.clone());
|
||||
write_json(&self.dirs.shares_file(), &shares)?;
|
||||
@@ -184,6 +263,11 @@ impl ShareStore {
|
||||
.iter_mut()
|
||||
.find(|share| share.id == id)
|
||||
.ok_or_else(|| Error::NotFound(format!("no share with id {id}")))?;
|
||||
if share.folder.is_some() {
|
||||
return Err(Error::Config(
|
||||
"that share is a folder of your account and has no link".to_owned(),
|
||||
));
|
||||
}
|
||||
if share.token != token {
|
||||
return Err(Error::Config(
|
||||
"that link is for a different share; add it as a new one".to_owned(),
|
||||
@@ -221,8 +305,20 @@ impl ShareStore {
|
||||
self.secrets.delete(id)
|
||||
}
|
||||
|
||||
/// Open a share as a visitor.
|
||||
pub async fn open(&self, share: &Share) -> Result<ProtonDrivePublicLinkClient> {
|
||||
/// Open a share: a link as a visitor, a folder through `account`.
|
||||
pub async fn open(&self, share: &Share, account: Option<&Account>) -> Result<ShareClient> {
|
||||
if let Some(folder) = &share.folder {
|
||||
let account = account.ok_or_else(|| {
|
||||
Error::SignedOut(format!(
|
||||
"sign in to Proton to open {}, a folder of your account",
|
||||
share.name
|
||||
))
|
||||
})?;
|
||||
return Ok(ShareClient::Account {
|
||||
client: account.drive().clone(),
|
||||
root: folder.uid(),
|
||||
});
|
||||
}
|
||||
let secrets = self.load_secrets(&share.id)?;
|
||||
let client = ProtonDrivePublicLinkClient::open(
|
||||
client_configuration(),
|
||||
@@ -234,7 +330,7 @@ impl ShareStore {
|
||||
// seeks wants more blocks in flight; at 4 MiB each this is a 192 MiB
|
||||
// ceiling, which is unremarkable for a desktop app.
|
||||
.with_max_inflight_blocks(48);
|
||||
Ok(client)
|
||||
Ok(ShareClient::PublicLink(Box::new(client)))
|
||||
}
|
||||
|
||||
fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> {
|
||||
@@ -256,7 +352,96 @@ impl ShareStore {
|
||||
|
||||
/// Several opened shares, presented as one library.
|
||||
pub struct SharedLibrary {
|
||||
clients: BTreeMap<String, ProtonDrivePublicLinkClient>,
|
||||
clients: BTreeMap<String, ShareClient>,
|
||||
}
|
||||
|
||||
/// One opened share, whichever kind it is.
|
||||
///
|
||||
/// Cheap to clone: both clients share their caches and sessions between
|
||||
/// clones.
|
||||
#[derive(Clone)]
|
||||
pub enum ShareClient {
|
||||
/// A public link, opened as a visitor. Boxed: the visitor client is
|
||||
/// several times the size of the account variant, and libraries are
|
||||
/// mostly links, so every map entry would otherwise pay for it.
|
||||
PublicLink(Box<ProtonDrivePublicLinkClient>),
|
||||
/// A folder of the signed-in account.
|
||||
Account {
|
||||
client: ProtonDriveClient,
|
||||
root: NodeUid,
|
||||
},
|
||||
}
|
||||
|
||||
impl ShareClient {
|
||||
/// The share's top folder.
|
||||
pub async fn root_node(&self) -> proton_sdk::error::Result<Node> {
|
||||
match self {
|
||||
Self::PublicLink(client) => client.get_root_node().await,
|
||||
Self::Account { client, root } => client.get_node(root).await?.ok_or_else(|| {
|
||||
proton_sdk::error::ProtonError::invalid_operation(
|
||||
"the folder is no longer in your Drive",
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn enumerate_folder_children_node_uids(
|
||||
&self,
|
||||
folder: &NodeUid,
|
||||
) -> proton_sdk::error::Result<Vec<NodeUid>> {
|
||||
match self {
|
||||
Self::PublicLink(client) => client.enumerate_folder_children_node_uids(folder).await,
|
||||
Self::Account { client, .. } => {
|
||||
client.enumerate_folder_children_node_uids(folder).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn enumerate_nodes(&self, uids: &[NodeUid]) -> proton_sdk::error::Result<Vec<Node>> {
|
||||
match self {
|
||||
Self::PublicLink(client) => client.enumerate_nodes(uids).await,
|
||||
Self::Account { client, .. } => client.enumerate_nodes(uids).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn open_revision(&self, uid: &NodeUid) -> proton_sdk::error::Result<RevisionReader> {
|
||||
match self {
|
||||
Self::PublicLink(client) => client.open_revision(uid).await,
|
||||
Self::Account { client, .. } => client.open_revision(uid).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_range(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
offset: u64,
|
||||
length: u64,
|
||||
) -> proton_sdk::error::Result<Vec<u8>> {
|
||||
match self {
|
||||
Self::PublicLink(client) => client.download_range(uid, offset, length).await,
|
||||
Self::Account { client, .. } => client.download_range(uid, offset, length).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_thumbnail(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
kind: ThumbnailType,
|
||||
) -> proton_sdk::error::Result<Option<Vec<u8>>> {
|
||||
match self {
|
||||
Self::PublicLink(client) => client.download_thumbnail(uid, kind).await,
|
||||
Self::Account { client, .. } => client.download_thumbnail(uid, kind).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Replay a visitor handshake. An account session refreshes its own
|
||||
/// tokens when one expires, so there is nothing to do for a folder.
|
||||
pub async fn refresh_session(&self) -> proton_sdk::error::Result<()> {
|
||||
match self {
|
||||
Self::PublicLink(client) => client.refresh_session().await,
|
||||
Self::Account { .. } => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SharedLibrary {
|
||||
@@ -265,8 +450,14 @@ impl SharedLibrary {
|
||||
/// A share that fails to open is reported and skipped rather than failing
|
||||
/// the whole library — one revoked link should not make the other three
|
||||
/// unwatchable.
|
||||
pub async fn open_all(store: &ShareStore) -> Result<(Self, Vec<(Share, Error)>)> {
|
||||
Self::open_all_reusing(store, BTreeMap::new()).await
|
||||
///
|
||||
/// Folders of the account open through `account`; without one they are
|
||||
/// reported as failures like any other share that will not open.
|
||||
pub async fn open_all(
|
||||
store: &ShareStore,
|
||||
account: Option<&Account>,
|
||||
) -> Result<(Self, Vec<(Share, Error)>)> {
|
||||
Self::open_all_reusing(store, BTreeMap::new(), account).await
|
||||
}
|
||||
|
||||
/// Open every configured share, reusing the clients in `reusable`.
|
||||
@@ -278,7 +469,8 @@ impl SharedLibrary {
|
||||
/// link.
|
||||
pub async fn open_all_reusing(
|
||||
store: &ShareStore,
|
||||
mut reusable: BTreeMap<String, ProtonDrivePublicLinkClient>,
|
||||
mut reusable: BTreeMap<String, ShareClient>,
|
||||
account: Option<&Account>,
|
||||
) -> Result<(Self, Vec<(Share, Error)>)> {
|
||||
let mut clients = BTreeMap::new();
|
||||
let mut failures = Vec::new();
|
||||
@@ -288,7 +480,7 @@ impl SharedLibrary {
|
||||
clients.insert(share.id, client);
|
||||
continue;
|
||||
}
|
||||
match store.open(&share).await {
|
||||
match store.open(&share, account).await {
|
||||
Ok(client) => {
|
||||
clients.insert(share.id.clone(), client);
|
||||
}
|
||||
@@ -305,19 +497,22 @@ impl SharedLibrary {
|
||||
/// The excluded share is the one whose credentials or membership just
|
||||
/// changed; leaving it out is what makes a removed share's live client
|
||||
/// unreachable rather than merely stale.
|
||||
pub fn reusable_clients(
|
||||
&self,
|
||||
dropping: &str,
|
||||
) -> BTreeMap<String, ProtonDrivePublicLinkClient> {
|
||||
///
|
||||
/// Folders of the account are never handed back: they carry the session
|
||||
/// they were opened with, and the account may have signed out or changed
|
||||
/// since.
|
||||
pub fn reusable_clients(&self, dropping: &str) -> BTreeMap<String, ShareClient> {
|
||||
self.clients
|
||||
.iter()
|
||||
.filter(|(id, _)| id.as_str() != dropping)
|
||||
.filter(|(id, client)| {
|
||||
id.as_str() != dropping && matches!(client, ShareClient::PublicLink(_))
|
||||
})
|
||||
.map(|(id, client)| (id.clone(), client.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The client for one share.
|
||||
pub fn client(&self, share_id: &str) -> Option<&ProtonDrivePublicLinkClient> {
|
||||
pub fn client(&self, share_id: &str) -> Option<&ShareClient> {
|
||||
self.clients.get(share_id)
|
||||
}
|
||||
|
||||
@@ -377,7 +572,7 @@ impl SharedLibrary {
|
||||
.client(share_id)
|
||||
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
|
||||
|
||||
let root = client.get_root_node().await?;
|
||||
let root = client.root_node().await?;
|
||||
let mut level = vec![root.uid.clone()];
|
||||
let mut found = vec![root];
|
||||
|
||||
@@ -433,7 +628,7 @@ const APP_VERSION: &str = concat!("external-drive-stream@", env!("CARGO_PKG_VERS
|
||||
const USER_AGENT: &str = concat!("proton-stream/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
/// The API configuration every visitor client is built from.
|
||||
fn client_configuration() -> ProtonClientConfiguration {
|
||||
pub(crate) fn client_configuration() -> ProtonClientConfiguration {
|
||||
ProtonClientConfiguration::new(APP_VERSION).with_user_agent(USER_AGENT)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
//! Watch history kept in the viewer's own Drive, so every device resumes where
|
||||
//! another left off.
|
||||
//!
|
||||
//! ## Layout
|
||||
//!
|
||||
//! The history lives on a Drive *device* of its own, named [`DEVICE_NAME`]: the
|
||||
//! same kind of entry a desktop sync client registers, so it shows up in the
|
||||
//! web app under Computers rather than as a stray folder in My Files. Inside
|
||||
//! it, `watch-history/` holds one JSON file per installation of this app,
|
||||
//! named by an id generated once and kept in [`AppDirs::sync_file`].
|
||||
//!
|
||||
//! ## Why a file per installation
|
||||
//!
|
||||
//! Drive has no merge: two devices writing one file is last writer wins for
|
||||
//! the *whole file*, and the loser's positions are gone. With a file each,
|
||||
//! every installation only ever writes its own and reads everyone else's, so
|
||||
//! nothing is overwritten and there is no write conflict to resolve.
|
||||
//!
|
||||
//! Each file carries the installation's whole watch table, and merging is per
|
||||
//! episode: the newer `updated_at` wins. That is idempotent and order-free, so
|
||||
//! it does not matter how many devices there are, which synced first, or that
|
||||
//! a device writes back entries it learned from another. A device whose clock
|
||||
//! runs ahead wins ties it should not, which is the price of not having a
|
||||
//! server.
|
||||
//!
|
||||
//! The keys travel: `share-{token}` is derived from the link and a folder's
|
||||
//! `drive-…` id from its node, so the same library added on two devices has
|
||||
//! the same `(share_id, link_id)` on both. Nothing secret is in a file — the
|
||||
//! link token decrypts nothing without the fragment — and the file is
|
||||
//! end-to-end encrypted by Drive regardless.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use proton_drive_rs::{DeviceType, NodeKind};
|
||||
use proton_sdk::ids::NodeUid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::account::Account;
|
||||
use crate::catalog::{Catalog, WatchState};
|
||||
use crate::config::{AppDirs, read_json, write_json};
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// The Drive device watch history is kept on.
|
||||
pub const DEVICE_NAME: &str = "proton-stream";
|
||||
|
||||
/// The folder on that device holding one file per installation.
|
||||
const FOLDER: &str = "watch-history";
|
||||
|
||||
/// Bumped only for a change an older build would misread. A file of a newer
|
||||
/// version is skipped rather than half-understood.
|
||||
const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
/// One installation's watch history, as stored in Drive.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
struct HistoryFile {
|
||||
version: u32,
|
||||
installation: String,
|
||||
entries: Vec<HistoryEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
struct HistoryEntry {
|
||||
share: String,
|
||||
link: String,
|
||||
position: f64,
|
||||
#[serde(default)]
|
||||
duration: Option<f64>,
|
||||
watched: bool,
|
||||
updated_at: i64,
|
||||
}
|
||||
|
||||
impl HistoryEntry {
|
||||
fn from_state((share, link): (String, String), state: WatchState) -> Self {
|
||||
Self {
|
||||
share,
|
||||
link,
|
||||
position: state.position_secs,
|
||||
duration: state.duration_secs,
|
||||
watched: state.watched,
|
||||
updated_at: state.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn state(&self) -> WatchState {
|
||||
WatchState {
|
||||
position_secs: self.position,
|
||||
duration_secs: self.duration,
|
||||
watched: self.watched,
|
||||
updated_at: self.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this entry could have come from this app. A file is another
|
||||
/// device's to write, so its contents are checked before they reach the
|
||||
/// catalog.
|
||||
fn is_sane(&self) -> bool {
|
||||
self.position.is_finite()
|
||||
&& self.position >= 0.0
|
||||
&& self
|
||||
.duration
|
||||
.is_none_or(|duration| duration.is_finite() && duration >= 0.0)
|
||||
&& !self.share.is_empty()
|
||||
&& !self.link.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// This installation's sync identity, kept beside the share list.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Identity {
|
||||
installation: String,
|
||||
}
|
||||
|
||||
/// What one [`WatchSync::sync`] did.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct SyncReport {
|
||||
/// Episodes whose position another device had newer.
|
||||
pub applied: usize,
|
||||
/// Whether this installation's file was written.
|
||||
pub uploaded: bool,
|
||||
/// Other installations whose history was read.
|
||||
pub devices: usize,
|
||||
}
|
||||
|
||||
/// A history file as last read, by the revision it was read at.
|
||||
struct Seen {
|
||||
revision: Option<String>,
|
||||
entries: Vec<HistoryEntry>,
|
||||
}
|
||||
|
||||
/// Syncs the catalog's watch table with the account's Drive.
|
||||
pub struct WatchSync {
|
||||
installation: String,
|
||||
/// Remote files by link id. A file whose active revision has not moved
|
||||
/// since it was read is not downloaded again.
|
||||
seen: Mutex<HashMap<String, Seen>>,
|
||||
/// What this installation's own file holds, once known: what was read
|
||||
/// back at the first sync, then what was last written. Compared against
|
||||
/// the catalog so an unchanged history is not uploaded as a new revision.
|
||||
own: Mutex<Option<Vec<HistoryEntry>>>,
|
||||
/// The history folders, resolved once per account. Usually one; see
|
||||
/// [`WatchSync::folders`].
|
||||
folders: Mutex<Option<Vec<NodeUid>>>,
|
||||
/// One sync at a time: two would race on creating the device and the
|
||||
/// installation's file.
|
||||
running: tokio::sync::Mutex<()>,
|
||||
}
|
||||
|
||||
impl WatchSync {
|
||||
/// This installation's sync, with its id read from — or, the first time,
|
||||
/// written to — [`AppDirs::sync_file`].
|
||||
pub fn open(dirs: &AppDirs) -> Result<Self> {
|
||||
let path = dirs.sync_file();
|
||||
let installation = match read_json::<Identity>(&path)? {
|
||||
Some(identity) => identity.installation,
|
||||
None => {
|
||||
let identity = Identity {
|
||||
installation: new_installation_id()?,
|
||||
};
|
||||
write_json(&path, &identity)?;
|
||||
identity.installation
|
||||
}
|
||||
};
|
||||
Ok(Self::with_installation(installation))
|
||||
}
|
||||
|
||||
fn with_installation(installation: String) -> Self {
|
||||
Self {
|
||||
installation,
|
||||
seen: Mutex::new(HashMap::new()),
|
||||
own: Mutex::new(None),
|
||||
folders: Mutex::new(None),
|
||||
running: tokio::sync::Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget everything learned about the account, after signing out or in
|
||||
/// as someone else.
|
||||
pub fn reset(&self) {
|
||||
self.seen.lock().clear();
|
||||
*self.own.lock() = None;
|
||||
*self.folders.lock() = None;
|
||||
}
|
||||
|
||||
/// Pull every other installation's history into `catalog`, then write
|
||||
/// this one's if it changed.
|
||||
///
|
||||
/// The catalog is locked only around its own reads and writes, never
|
||||
/// across a request, so playback can keep saving its position while this
|
||||
/// runs.
|
||||
pub async fn sync(&self, account: &Account, catalog: &Mutex<Catalog>) -> Result<SyncReport> {
|
||||
let _running = self.running.lock().await;
|
||||
let drive = account.drive();
|
||||
let folders = self.folders(account).await?;
|
||||
let own_name = format!("{}.json", self.installation);
|
||||
|
||||
let mut own_file = None;
|
||||
let mut remote = Vec::new();
|
||||
let mut devices = 0;
|
||||
for folder in &folders {
|
||||
let uids = drive.enumerate_folder_children_node_uids(folder).await?;
|
||||
if uids.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for node in drive.enumerate_nodes(&uids).await? {
|
||||
let NodeKind::File {
|
||||
active_revision_id, ..
|
||||
} = &node.kind
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if node.trashed || !node.name.ends_with(".json") {
|
||||
continue;
|
||||
}
|
||||
if node.name == own_name {
|
||||
if own_file.is_none() {
|
||||
own_file = Some((node.uid.clone(), active_revision_id.clone()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
devices += 1;
|
||||
remote.extend(
|
||||
self.read(account, &node.uid, active_revision_id.clone())
|
||||
.await,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if self.own.lock().is_none() {
|
||||
let entries = match &own_file {
|
||||
Some((uid, revision)) => self.read(account, uid, revision.clone()).await,
|
||||
None => Vec::new(),
|
||||
};
|
||||
// What this installation wrote before is history too: after a
|
||||
// reinstall that kept the config, or a catalog that was deleted.
|
||||
remote.extend(entries.iter().cloned());
|
||||
*self.own.lock() = Some(entries);
|
||||
}
|
||||
|
||||
let incoming: Vec<(String, String, WatchState)> = remote
|
||||
.iter()
|
||||
.filter(|entry| entry.is_sane())
|
||||
.map(|entry| (entry.share.clone(), entry.link.clone(), entry.state()))
|
||||
.collect();
|
||||
let (applied, local) = {
|
||||
let mut catalog = catalog.lock();
|
||||
let applied = catalog.merge_watch_states(&incoming)?;
|
||||
(applied, catalog.all_watch_states()?)
|
||||
};
|
||||
|
||||
let mut entries: Vec<HistoryEntry> = local
|
||||
.into_iter()
|
||||
.map(|(key, state)| HistoryEntry::from_state(key, state))
|
||||
.collect();
|
||||
entries.sort_by(|a, b| (&a.share, &a.link).cmp(&(&b.share, &b.link)));
|
||||
|
||||
let unchanged = self.own.lock().as_ref() == Some(&entries);
|
||||
let uploaded = if unchanged || (entries.is_empty() && own_file.is_none()) {
|
||||
false
|
||||
} else {
|
||||
let file = HistoryFile {
|
||||
version: FORMAT_VERSION,
|
||||
installation: self.installation.clone(),
|
||||
entries: entries.clone(),
|
||||
};
|
||||
let bytes = serde_json::to_vec(&file)
|
||||
.map_err(|e| Error::Config(format!("serialize watch history: {e}")))?;
|
||||
match &own_file {
|
||||
Some((uid, _)) => drive.upload_new_revision(uid, &bytes).await?,
|
||||
None => {
|
||||
drive
|
||||
.upload_file(&folders[0], &own_name, "application/json", &bytes)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
*self.own.lock() = Some(entries);
|
||||
true
|
||||
};
|
||||
|
||||
Ok(SyncReport {
|
||||
applied,
|
||||
uploaded,
|
||||
devices,
|
||||
})
|
||||
}
|
||||
|
||||
/// The entries of one history file, from the cache when its revision has
|
||||
/// not moved.
|
||||
///
|
||||
/// A file that will not download or parse is logged and read as empty: it
|
||||
/// is another device's, and one bad file must not stop the rest syncing.
|
||||
async fn read(
|
||||
&self,
|
||||
account: &Account,
|
||||
uid: &NodeUid,
|
||||
revision: Option<String>,
|
||||
) -> Vec<HistoryEntry> {
|
||||
let key = uid.link_id.as_str().to_owned();
|
||||
if let Some(seen) = self.seen.lock().get(&key)
|
||||
&& revision.is_some()
|
||||
&& seen.revision == revision
|
||||
{
|
||||
return seen.entries.clone();
|
||||
}
|
||||
let entries = match account.drive().download_file(uid).await {
|
||||
Ok(bytes) => match parse(&bytes) {
|
||||
Some(entries) => entries,
|
||||
None => {
|
||||
tracing::warn!(%uid, "skipping a watch-history file this build cannot read");
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
tracing::warn!(%uid, %error, "a watch-history file could not be downloaded");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
self.seen.lock().insert(
|
||||
key,
|
||||
Seen {
|
||||
revision,
|
||||
entries: entries.clone(),
|
||||
},
|
||||
);
|
||||
entries
|
||||
}
|
||||
|
||||
/// The history folders, creating the device and folder the first time.
|
||||
///
|
||||
/// Two installations signing in at the same moment can each create the
|
||||
/// device before seeing the other's. Every device of that name is read
|
||||
/// from so neither's history is lost; the oldest is the one written to,
|
||||
/// which is the same one on every installation.
|
||||
async fn folders(&self, account: &Account) -> Result<Vec<NodeUid>> {
|
||||
if let Some(folders) = self.folders.lock().clone() {
|
||||
return Ok(folders);
|
||||
}
|
||||
let drive = account.drive();
|
||||
let mut devices: Vec<_> = drive
|
||||
.enumerate_devices()
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|device| device.name.as_deref().ok() == Some(DEVICE_NAME))
|
||||
.collect();
|
||||
devices.sort_by_key(|device| device.creation_time);
|
||||
if devices.is_empty() {
|
||||
devices.push(drive.create_device(DEVICE_NAME, device_type()).await?);
|
||||
}
|
||||
let mut folders = Vec::with_capacity(devices.len());
|
||||
for device in &devices {
|
||||
folders.push(
|
||||
drive
|
||||
.create_folder_path(&device.root_folder_uid, FOLDER)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
*self.folders.lock() = Some(folders.clone());
|
||||
Ok(folders)
|
||||
}
|
||||
}
|
||||
|
||||
/// A history file's entries, or `None` for one this build cannot read.
|
||||
fn parse(bytes: &[u8]) -> Option<Vec<HistoryEntry>> {
|
||||
let file: HistoryFile = serde_json::from_slice(bytes).ok()?;
|
||||
(file.version <= FORMAT_VERSION).then_some(file.entries)
|
||||
}
|
||||
|
||||
/// The kind of device to register, which is only the icon the web app draws.
|
||||
fn device_type() -> DeviceType {
|
||||
if cfg!(target_os = "windows") {
|
||||
DeviceType::Windows
|
||||
} else if cfg!(target_os = "macos") {
|
||||
DeviceType::MacOs
|
||||
} else {
|
||||
DeviceType::Linux
|
||||
}
|
||||
}
|
||||
|
||||
fn new_installation_id() -> Result<String> {
|
||||
let mut bytes = [0_u8; 16];
|
||||
getrandom::fill(&mut bytes)
|
||||
.map_err(|e| Error::Config(format!("no randomness for an installation id: {e}")))?;
|
||||
Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(link: &str, position: f64, updated_at: i64) -> HistoryEntry {
|
||||
HistoryEntry {
|
||||
share: "share-a".to_owned(),
|
||||
link: link.to_owned(),
|
||||
position,
|
||||
duration: Some(1440.0),
|
||||
watched: false,
|
||||
updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_installation_keeps_the_id_it_was_given() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("pstr-sync-{}", new_installation_id().unwrap()));
|
||||
let dirs = AppDirs::from_paths(root.join("c"), root.join("d"), root.join("k")).unwrap();
|
||||
let first = WatchSync::open(&dirs).unwrap().installation;
|
||||
let second = WatchSync::open(&dirs).unwrap().installation;
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(first.len(), 32);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_history_file_round_trips() {
|
||||
let file = HistoryFile {
|
||||
version: FORMAT_VERSION,
|
||||
installation: "abc".to_owned(),
|
||||
entries: vec![entry("a", 12.5, 100)],
|
||||
};
|
||||
let bytes = serde_json::to_vec(&file).unwrap();
|
||||
assert_eq!(parse(&bytes), Some(file.entries));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_history_file_from_a_newer_format_is_not_read() {
|
||||
let file = HistoryFile {
|
||||
version: FORMAT_VERSION + 1,
|
||||
installation: "abc".to_owned(),
|
||||
entries: vec![entry("a", 12.5, 100)],
|
||||
};
|
||||
assert_eq!(parse(&serde_json::to_vec(&file).unwrap()), None);
|
||||
assert_eq!(parse(b"not json"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_another_device_could_not_have_written_are_refused() {
|
||||
assert!(entry("a", 1.0, 1).is_sane());
|
||||
assert!(!entry("a", f64::NAN, 1).is_sane());
|
||||
assert!(!entry("a", -1.0, 1).is_sane());
|
||||
assert!(!entry("", 1.0, 1).is_sane());
|
||||
let mut infinite = entry("a", 1.0, 1);
|
||||
infinite.duration = Some(f64::INFINITY);
|
||||
assert!(!infinite.is_sane());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entry_carries_its_watch_state_unchanged() {
|
||||
let state = WatchState {
|
||||
position_secs: 61.0,
|
||||
duration_secs: None,
|
||||
watched: true,
|
||||
updated_at: 42,
|
||||
};
|
||||
let entry = HistoryEntry::from_state(("s".to_owned(), "l".to_owned()), state);
|
||||
assert_eq!(entry.state(), state);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user