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:
nvrl committed 2026-10-06 13:06:54 +02:00
1 parent 6cdab2fde1
commit 0b2813cd42
37 files changed
+4111 -159

No files matched your search

+2
View File
@@ -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
+635
View File
@@ -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}");
}
}
}
+103
View File
@@ -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");
+6
View File
@@ -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")
+31
View File
@@ -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"))]
+4 -1
View File
@@ -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
View File
@@ -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)
}
+457
View File
@@ -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);
}
}