feat(core,app,android,cli,ios): sync the library and settings, verify a share before adding it

Sync carried only watch positions, so a second device started empty. The
per-installation history file now also carries per-key registers
(pstr-core::profile): every share with its link and custom password, the
playback, appearance and metadata settings, the TMDB key, hand-picked
matches and per-title track choices. Newer updated_at wins per key, a
removal travels as an empty register, and a first sync stamps at 0 so a
fresh install takes the library the other devices have instead of
overwriting it with defaults. Volume, mute and the device-only settings
(cache budget, Wi-Fi only, background audio, hardware decoding) stay out.
The secrets live only in the end-to-end encrypted Drive file, never in
shares.json. FORMAT_VERSION stays 1; an older build ignores the registers.

Adding a share stored it before opening the link, so a first failure left a
broken entry and every retry said "already configured". add_verified opens
the link first and stores nothing on failure, and only the new share is
crawled afterwards, so another broken share cannot fail the add.

Android drops picture-in-picture: the window sized the video wrongly and
background audio already covers leaving the app.

iOS sizes the Metal drawable on every layout, so the picture no longer
stretches after a rotation or a Split View resize. Settings show a save at
once rather than after the match run, and a failed save reverts and says
why. Both phone clients gain an activity banner naming what is running, and
the add-share, metadata and change-match forms show progress and errors in
place.
This commit is contained in:
nvrl committed 2026-10-09 03:15:39 +02:00
1 parent dc70cafbb7
commit 8e42906a7f
37 files changed
+2018 -594

No files matched your search

+151 -41
View File
@@ -187,6 +187,16 @@ pub struct SyncRecord {
pub uploaded: bool,
/// Other installations whose history was read.
pub devices: u32,
/// Shares another device added or changed, configured here and not yet
/// crawled. Crawl each.
pub shares_added: Vec<String>,
/// Shares another device removed, gone here with their offline files.
/// Drop any download state kept for them.
pub shares_removed: Vec<String>,
/// Whether settings changed: repaint, and re-read the metadata settings.
pub settings_changed: bool,
/// Hand-picked matches and per-show track choices taken.
pub titles: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
@@ -704,6 +714,22 @@ impl<T> Drop for AbortOnDrop<T> {
}
}
/// Why a link could not be added, in words a viewer can act on.
///
/// A link that is already in the library and one that is not a link at all
/// say so themselves; anything else failed at the handshake, where a wrong
/// custom password and an expired link look the same from here.
fn add_share_failure(error: &pstr_core::Error) -> String {
match error {
pstr_core::Error::Config(reason) => reason.clone(),
pstr_core::Error::Drive(_) => format!(
"Could not open that link: {error}. Check that it is complete, \
still shared, and that the password is right."
),
other => other.to_string(),
}
}
/// `runtime.spawn(task)`, with the handle wrapped so the task is aborted if the
/// caller stops waiting. Awaits to exactly what `JoinHandle` does.
fn spawned<T: Send + 'static>(
@@ -1143,12 +1169,31 @@ impl AndroidEngine {
else {
return Ok(None);
};
match sync.sync(&account, &self.catalog).await {
Ok(report) => Ok(Some(SyncRecord {
applied: u32::try_from(report.applied).unwrap_or(u32::MAX),
uploaded: report.uploaded,
devices: u32::try_from(report.devices).unwrap_or(u32::MAX),
})),
match sync.sync(&account, &self.catalog, &self.store).await {
Ok(report) => {
let profile = report.profile;
for id in profile.shares_added.iter().chain(&profile.shares_changed) {
self.invalidate_connection(id);
}
for id in &profile.shares_removed {
if let Err(error) = self.forget_synced_share(id) {
log::warn!("files of {id}, a share removed elsewhere: {error}");
}
}
Ok(Some(SyncRecord {
applied: u32::try_from(report.applied).unwrap_or(u32::MAX),
uploaded: report.uploaded,
devices: u32::try_from(report.devices).unwrap_or(u32::MAX),
shares_added: profile
.shares_added
.into_iter()
.chain(profile.shares_changed)
.collect(),
shares_removed: profile.shares_removed,
settings_changed: profile.settings,
titles: u32::try_from(profile.titles).unwrap_or(u32::MAX),
}))
}
Err(error) if error.is_signed_out() => {
let _ = self.accounts.sign_out(None).await;
self.signed_out();
@@ -1223,6 +1268,26 @@ impl AndroidEngine {
Ok(share_record(share))
}
/// Configured shares with nothing in the catalog: brought in by sync,
/// possibly while the app was in the background, or added here and never
/// crawled to the end. Crawl each. An empty share is crawled every time,
/// which costs one listing.
pub fn uncrawled_shares(&self) -> Result<Vec<String>, BridgeError> {
let shares = self.store.list().map_err(BridgeError::from_display)?;
let catalog = self.catalog.lock();
let mut uncrawled = Vec::new();
for share in shares {
if catalog
.files(&share.id)
.map_err(BridgeError::from_display)?
.is_empty()
{
uncrawled.push(share.id);
}
}
Ok(uncrawled)
}
pub fn shares(&self) -> Result<Vec<ShareRecord>, BridgeError> {
self.store
.list()
@@ -1230,18 +1295,34 @@ impl AndroidEngine {
.map(|shares| shares.into_iter().map(share_record).collect())
}
pub fn add_share(
&self,
/// Add a public link, once it has opened with the password given. A link
/// that will not open is refused and leaves nothing behind. Crawl the
/// returned share next.
pub async fn add_share(
self: Arc<Self>,
name: String,
url: String,
custom_password: Option<String>,
) -> Result<ShareRecord, BridgeError> {
let share = self
.store
.add(&name, &url, custom_password.as_deref())
.map_err(BridgeError::from_display)?;
self.invalidate_connection(&share.id);
Ok(share_record(share))
let runtime = Arc::clone(&self.runtime);
spawned(&runtime, async move {
let (share, client) = self
.store
.add_verified(&name, &url, custom_password.as_deref())
.await
.map_err(|error| BridgeError::Failure {
reason: add_share_failure(&error),
})?;
self.invalidate_connection(&share.id);
// The handshake that proved the link is the one the crawl uses.
self.reusable_clients
.lock()
.get_or_insert_with(Default::default)
.insert(share.id.clone(), client);
Ok(share_record(share))
})
.await
.map_err(BridgeError::from_display)?
}
/// Re-supply the link behind a share whose stored secret is unreadable.
@@ -1291,28 +1372,7 @@ impl AndroidEngine {
// when secret cleanup fails, old authenticated clients and catalog
// rows must not remain usable.
self.invalidate_connection(&share_id);
for ((stored_share_id, link_id), file) in offline {
if stored_share_id == share_id {
let path = self
.dirs
.offline_file(&share_id, &link_id, &file.revision_id);
match std::fs::remove_file(&path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(BridgeError::from_display(error)),
}
}
}
for link_id in links {
let (partial, marker) = self.partial_paths(&share_id, &link_id);
let _ = std::fs::remove_file(partial);
let _ = std::fs::remove_file(marker);
}
self.catalog
.lock()
.remove_share(&share_id)
.map_err(BridgeError::from_display)?;
self.forget_share_files(&share_id, offline, links)?;
store_result.map_err(BridgeError::from_display)
}
@@ -1901,6 +1961,56 @@ impl AndroidEngine {
}
impl AndroidEngine {
/// Delete a removed share's offline and partial files, then its catalog
/// rows. Watch state stays; see `Catalog::remove_share`.
fn forget_share_files(
&self,
share_id: &str,
offline: HashMap<(String, String), OfflineFile>,
links: Vec<String>,
) -> Result<(), BridgeError> {
for ((stored_share_id, link_id), file) in offline {
if stored_share_id == share_id {
let path = self
.dirs
.offline_file(share_id, &link_id, &file.revision_id);
match std::fs::remove_file(&path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(BridgeError::from_display(error)),
}
}
}
for link_id in links {
let (partial, marker) = self.partial_paths(share_id, &link_id);
let _ = std::fs::remove_file(partial);
let _ = std::fs::remove_file(marker);
}
self.catalog
.lock()
.remove_share(share_id)
.map_err(BridgeError::from_display)
}
/// [`Self::forget_share_files`] for a share another device removed, which
/// sync has already taken out of the configuration.
fn forget_synced_share(&self, share_id: &str) -> Result<(), BridgeError> {
let _publication = self.share_publication.lock();
let catalog = self.catalog.lock();
let offline = catalog
.all_offline_files()
.map_err(BridgeError::from_display)?;
let links: Vec<String> = catalog
.files(share_id)
.map_err(BridgeError::from_display)?
.into_iter()
.map(|node| node.link_id)
.collect();
drop(catalog);
self.invalidate_connection(share_id);
self.forget_share_files(share_id, offline, links)
}
/// One enrichment pass over the whole library.
///
/// What to do per title is decided by `pstr_meta::service::plan`, the same
@@ -3765,13 +3875,13 @@ mod tests {
)
.expect("engine");
// Stored directly: `add_share` opens the link first, and a test has
// no network to open it over.
let share = engine
.add_share(
"test".to_owned(),
"https://drive.proton.me/urls/ABC123#s3cr3t".to_owned(),
None,
)
.store
.add("test", "https://drive.proton.me/urls/ABC123#s3cr3t", None)
.expect("add share");
engine.invalidate_connection(&share.id);
assert_eq!(engine.share_generation.load(Ordering::Acquire), 1);
assert!(
engine
+4
View File
@@ -793,6 +793,10 @@ impl App {
self.view.stale = true;
}
Event::EpisodeMetadata(episodes) => self.episodes = episodes,
Event::Appearance(appearance) => {
theme::apply(ctx, appearance);
self.view.stale = true;
}
Event::MetadataConfig(config) => {
self.settings = config;
self.posters.clear();
+84 -11
View File
@@ -34,6 +34,7 @@ use pstr_core::config::AppDirs;
use pstr_core::library::{Library, Title, TitleKind};
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId};
use pstr_core::prefs::PlaybackPrefs;
use pstr_core::profile::ProfileChanges;
use pstr_core::proton_drive_rs::ThumbnailType;
use pstr_core::proton_sdk::api::HumanVerificationCredential;
use pstr_core::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
@@ -215,6 +216,8 @@ pub enum Event {
EpisodeMetadata(HashMap<String, EpisodeGuide>),
/// The enrichment settings changed, on disk.
MetadataConfig(MetadataConfig),
/// Another device's appearance arrived through sync.
Appearance(Appearance),
/// A matching run finished, with how it went.
Matched {
matched: usize,
@@ -279,7 +282,7 @@ pub enum Event {
SignInFailed(String),
/// Watch history synced with the account's Drive.
Synced(SyncReport),
/// Watch history did not sync, with why. Not a toast: it is retried on a
/// Sync did not run, with why. Not a toast: it is retried on a
/// timer, and an offline evening would be a stack of them.
SyncFailed(String),
/// The account's top-level places, for the Drive browser.
@@ -640,12 +643,17 @@ impl Engine {
}
/// Add a share, then open and crawl it — which is what "add" means to
/// someone who just pasted a link.
/// someone who just pasted a link. A link that will not open is refused
/// before anything is stored, so trying again is not "already configured".
pub fn add_share(&self, name: String, url: String, password: Option<String>) {
let engine = self.clone();
self.runtime.spawn(async move {
let share = match engine.store.add(&name, &url, password.as_deref()) {
Ok(share) => share,
let share = match engine
.store
.add_verified(&name, &url, password.as_deref())
.await
{
Ok((share, _)) => share,
Err(error) => return engine.emit(Event::ShareRejected(error.to_string())),
};
engine.emit(Event::ShareAdded(share.name.clone()));
@@ -836,9 +844,10 @@ impl Engine {
};
let engine = self.clone();
self.runtime.spawn(async move {
match sync.sync(&account, &engine.catalog).await {
match sync.sync(&account, &engine.catalog, &engine.store).await {
Ok(report) => {
if report.applied > 0 {
engine.take_synced_profile(&report.profile).await;
if report.applied > 0 || report.profile.titles > 0 {
engine.load_library();
}
engine.emit(Event::Synced(report));
@@ -875,8 +884,9 @@ impl Engine {
return;
};
let catalog = Arc::clone(&self.catalog);
let store = Arc::clone(&self.store);
self.runtime.block_on(async move {
match tokio::time::timeout(timeout, sync.sync(&account, &catalog)).await {
match tokio::time::timeout(timeout, sync.sync(&account, &catalog, &store)).await {
Ok(Ok(_)) => {}
Ok(Err(error)) => tracing::warn!("sync watch history on exit: {error}"),
Err(_) => tracing::warn!("sync watch history on exit: timed out"),
@@ -884,6 +894,58 @@ impl Engine {
});
}
/// Act on what sync brought from other devices: reread the settings it
/// rewrote, crawl the shares it added, drop the ones it removed.
async fn take_synced_profile(&self, profile: &ProfileChanges) {
if profile.settings {
match pstr_core::prefs::load(&self.dirs) {
// The volume is this device's and was not synced; the file
// still holds it.
Ok(prefs) => *self.prefs.lock() = prefs,
Err(error) => tracing::warn!("reread the playback preferences: {error}"),
}
match pstr_core::appearance::load(&self.dirs) {
Ok(appearance) => {
*self.appearance.lock() = appearance;
self.emit(Event::Appearance(appearance));
}
Err(error) => tracing::warn!("reread the appearance: {error}"),
}
match pstr_meta::settings::load(&self.dirs) {
Ok(config) => {
let engine = self.clone();
let _ = self
.runtime
.spawn_blocking(move || {
engine.install_enrichment(Enrichment::build(config.clone()), false);
engine.emit(Event::MetadataConfig(config));
})
.await;
}
Err(error) => tracing::warn!("reread the metadata settings: {error}"),
}
}
if profile.titles > 0 {
match self.catalog.lock().all_title_track_prefs() {
Ok(prefs) => *self.track_prefs.lock() = prefs,
Err(error) => tracing::warn!("reread show track preferences: {error}"),
}
self.load_metadata();
}
for id in &profile.shares_removed {
self.forget_share(id.clone(), false).await;
}
if !profile.shares() {
return;
}
self.connection.lock().take();
self.load_shares();
self.load_library();
for id in profile.shares_added.iter().chain(&profile.shares_changed) {
self.connect_and_crawl(Some(id.clone())).await;
}
}
fn sync_periodically(&self) {
let engine = self.clone();
self.runtime.spawn(async move {
@@ -929,6 +991,19 @@ impl Engine {
pub fn remove_share(&self, id: String) {
let engine = self.clone();
self.runtime.spawn(async move {
engine.forget_share(id, true).await;
engine.connection.lock().take();
engine.load_library();
engine.emit(Event::Status("removed".into()));
});
}
/// Stop a share's downloads and delete its files and catalog rows, and,
/// when it is still `configured` — not already removed by sync — its
/// entry and secrets.
async fn forget_share(&self, id: String, configured: bool) {
let engine = self;
{
let keys: Vec<_> = engine
.downloads
.lock()
@@ -971,7 +1046,7 @@ impl Engine {
if let Err(error) = engine.catalog.lock().remove_share(&id) {
return engine.fail("drop that share's catalog rows", error);
}
if let Err(error) = engine.store.remove(&id) {
if configured && let Err(error) = engine.store.remove(&id) {
return engine.fail("remove that share", error);
}
{
@@ -983,9 +1058,7 @@ impl Engine {
engine.load_shares();
// Its clients are stale now; the next action reopens what is left.
engine.connection.lock().take();
engine.load_library();
engine.emit(Event::Status("removed".into()));
});
}
}
// ------------------------------------------------------------ connection
+8 -5
View File
@@ -133,9 +133,10 @@ impl DriveBrowser {
pub fn section(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
ui::section(ui, "Proton account");
ui.label(ui::muted(
"Optional. Signed in, your watch history is kept in your own Drive and every device \
resumes where another left off, and folders of your Drive — your files, other \
devices, what was shared with you — can be added to the library.",
"Optional. Signed in, your shares, settings and watch history are kept in your own \
Drive, so every device shows the same library and resumes where another left off, \
and folders of your Drive — your files, other devices, what was shared with you — \
can be added to the library.",
));
ui.add_space(theme::space::L);
@@ -281,9 +282,11 @@ fn signed_in(
.strong(),
);
let status = match (&panel.sync_error, &panel.synced) {
(Some(error), _) => format!("Watch history did not sync: {error}"),
(Some(error), _) => format!("Did not sync: {error}"),
(None, Some((at, report))) => sync_line(*at, report, unix_now()),
(None, None) => "Watch history syncs with your Drive".to_owned(),
(None, None) => {
"Library, settings and watch history sync through your Drive".to_owned()
}
};
ui.label(ui::muted(status));
});
+23 -4
View File
@@ -256,8 +256,9 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
} else {
None
};
let share = store
.add(&name, &url, password.as_deref())
let (share, _) = store
.add_verified(&name, &url, password.as_deref())
.await
.context("add share")?;
println!("added {} ({})", share.name, share.id);
}
@@ -323,11 +324,29 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
let sync = WatchSync::open(&dirs).context("open sync state")?;
let catalog =
parking_lot::Mutex::new(Catalog::open(&dirs.catalog_db()).context("open catalog")?);
let report = sync.sync(&account, &catalog).await.context("sync")?;
let report = sync
.sync(&account, &catalog, &store)
.await
.context("sync")?;
let profile = &report.profile;
for id in &profile.shares_removed {
catalog
.lock()
.remove_share(id)
.context("drop a removed share")?;
}
println!(
"read {} other device(s), took {} newer position(s), {}",
"read {} other device(s), took {} newer position(s), \
{} share(s) added, {} removed, settings {}, {}",
report.devices,
report.applied,
profile.shares_added.len() + profile.shares_changed.len(),
profile.shares_removed.len(),
if profile.settings {
"updated"
} else {
"unchanged"
},
if report.uploaded {
"uploaded this device's history"
} else {
+1
View File
@@ -17,6 +17,7 @@ pub mod library;
pub mod metadata;
pub mod naming;
pub mod prefs;
pub mod profile;
pub mod shares;
pub mod shelves;
pub mod sync;
+808
View File
@@ -0,0 +1,808 @@
//! The rest of what makes the app look the same on every device: the shares,
//! the settings, the hand-picked metadata matches and the per-show track
//! choices. Synced next to watch history by [`crate::sync`], in the same file.
//!
//! ## Registers
//!
//! Each synced thing is one *register*: a key (`share/<id>`,
//! `settings/playback`, `tracks/<title>`, …), its latest value, and when it
//! last changed. Merging is the same rule as watch history — the newer
//! `updated_at` wins, per key — so it is idempotent and order-free across any
//! number of devices. A removed share is a register with no value, so a
//! removal travels like any other change.
//!
//! ## Noticing changes
//!
//! Nothing that writes a setting or a share knows about sync. Instead
//! `sync-state.json` remembers every register as last synced, and each sync
//! compares it with what is on this device: whatever differs changed since,
//! and is stamped with the time of the sync. That costs a change made between
//! syncs its exact time, which matters only when two devices change the same
//! setting within one sync interval of each other.
//!
//! The first sync of an installation stamps what it finds at zero rather than
//! now. A fresh install holds only defaults, and those must not outrank the
//! library every other device already agreed on; what it has that nobody else
//! does is still added, since nothing older competes with it.
//!
//! ## What does not travel
//!
//! The volume and mute, which belong to the speakers in front of the viewer,
//! and every setting the front ends keep themselves: the cache budget, Wi-Fi
//! only, background audio, hardware decoding, window geometry.
//!
//! ## Secrets
//!
//! A share's register carries its link, fragment included, and its custom
//! password, and the TMDB API key travels too: without them another device
//! could list the share but never open it. The file holding them is in the
//! viewer's own Drive, end-to-end encrypted with their account's keys, which is
//! the same trust the credential store gives them locally; they are never
//! written anywhere else in plain text.
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::catalog::{Catalog, TitleTrackPrefs};
use crate::config::{AppDirs, read_json, write_json};
use crate::error::Result;
use crate::metadata::{MetadataConfig, MetadataRecord, ProviderId, TitleMetadata};
use crate::shares::{AccountFolder, Share, ShareSecrets, ShareStore};
const SHARE: &str = "share/";
const TRACKS: &str = "tracks/";
const MATCH: &str = "match/";
/// The settings files that travel, by register, and the fields in each that
/// stay on the device that wrote them.
const SETTINGS: [(&str, &str, &[&str]); 3] = [
("settings/playback", "playback.json", &["volume", "muted"]),
("settings/appearance", "appearance.json", &[]),
("settings/metadata", "metadata.json", &[]),
];
const METADATA_SETTINGS: &str = "settings/metadata";
/// One synced value and when it last changed.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct Register {
/// `None` is a removal.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<Value>,
pub updated_at: i64,
}
pub(crate) type Registers = BTreeMap<String, Register>;
/// What this installation last synced, kept beside its sync identity.
#[derive(Debug, Default, Serialize, Deserialize)]
struct State {
registers: Registers,
}
/// A share as it travels: everything another device needs to open it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct SyncedShare {
name: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
custom_password: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
folder: Option<AccountFolder>,
}
/// A match the viewer picked by hand. Automatic ones are not synced: every
/// device with enrichment on finds them again, and they expire anyway.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct SyncedMatch {
provider: ProviderId,
metadata: Option<TitleMetadata>,
fetched_at: i64,
}
/// What applying other devices' registers changed here, for the front end to
/// act on: reopen and crawl shares, repaint, reload the library.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProfileChanges {
/// Shares this device did not have, now configured. Not yet crawled.
pub shares_added: Vec<String>,
/// Shares whose name or secrets changed.
pub shares_changed: Vec<String>,
/// Shares removed elsewhere, now gone from the configuration here. Their
/// catalog rows and offline files are the front end's to drop, the same
/// way it does for a share removed on this device.
pub shares_removed: Vec<String>,
/// Whether any settings file or the TMDB key changed.
pub settings: bool,
/// Hand-picked matches and per-show track choices taken.
pub titles: usize,
}
impl ProfileChanges {
pub fn is_empty(&self) -> bool {
*self == Self::default()
}
pub fn shares(&self) -> bool {
!(self.shares_added.is_empty()
&& self.shares_changed.is_empty()
&& self.shares_removed.is_empty())
}
}
/// The local side of profile sync: where the state file lives, and the stores
/// registers are read from and applied to.
pub(crate) struct Profile<'a> {
pub store: &'a ShareStore,
pub catalog: &'a Mutex<Catalog>,
}
/// What is on this device, register by register.
#[derive(Debug, Default)]
struct Observed {
values: BTreeMap<String, Value>,
/// Keys that exist but could not be read — a share whose secret will not
/// decrypt. Neither a change nor a removal.
unknown: BTreeSet<String>,
/// Whether the share list itself could be read. Without it, a share
/// missing from `values` says nothing about whether it was removed.
shares_listed: bool,
}
fn state_file(dirs: &AppDirs) -> PathBuf {
dirs.config.join("sync-state.json")
}
fn tmdb_secret() -> String {
format!("metadata-{}", ProviderId::Tmdb.as_str())
}
const TMDB_KEY: &str = "secret/metadata-tmdb";
impl Profile<'_> {
fn dirs(&self) -> &AppDirs {
&self.store.dirs
}
/// Read the state, notice what changed here since, take what other
/// devices have newer, and write the state back. Returns the registers to
/// upload and what changed on this device.
pub fn sync<'r>(
&self,
remote: impl IntoIterator<Item = &'r Registers>,
now: i64,
) -> Result<(Registers, ProfileChanges)> {
let path = state_file(self.dirs());
let stored = read_json::<State>(&path)?;
let first = stored.is_none();
let mut state = stored.unwrap_or_default();
let observed = self.observe();
stamp(&mut state.registers, observed, if first { 0 } else { now });
let winners = merge(&state.registers, remote);
let changes = self.apply(&mut state.registers, winners);
write_json(&path, &state)?;
Ok((state.registers, changes))
}
fn observe(&self) -> Observed {
let mut observed = Observed::default();
for (key, file, local) in SETTINGS {
match read_json::<Value>(&self.dirs().config.join(file)) {
Ok(Some(value)) => {
observed
.values
.insert(key.to_owned(), without(value, local));
}
Ok(None) => {}
Err(error) => {
tracing::warn!(%error, file, "a settings file was not synced");
observed.unknown.insert(key.to_owned());
}
}
}
match self.store.secrets.get(&tmdb_secret()) {
Ok(Some(key)) => {
observed
.values
.insert(TMDB_KEY.to_owned(), Value::String(key));
}
Ok(None) => {}
Err(_) => {
observed.unknown.insert(TMDB_KEY.to_owned());
}
}
if let Ok(shares) = self.store.list() {
observed.shares_listed = true;
for share in shares {
let key = format!("{SHARE}{}", share.id);
let secrets = if share.folder.is_some() {
None
} else {
match self.store.secrets_of(&share.id) {
Ok(secrets) => Some(secrets),
Err(_) => {
observed.unknown.insert(key);
continue;
}
}
};
let synced = SyncedShare {
name: share.name,
token: share.token,
url: secrets.as_ref().map(|secrets| secrets.url.clone()),
custom_password: secrets.and_then(|secrets| secrets.custom_password),
folder: share.folder,
};
if let Ok(value) = serde_json::to_value(synced) {
observed.values.insert(key, value);
}
}
}
let catalog = self.catalog.lock();
if let Ok(tracks) = catalog.all_title_track_prefs() {
for (title, prefs) in tracks {
if let Ok(value) = serde_json::to_value(prefs) {
observed.values.insert(format!("{TRACKS}{title}"), value);
}
}
}
if let Ok(records) = catalog.all_metadata() {
for (title, record) in records {
if !record.manual {
continue;
}
let synced = SyncedMatch {
provider: record.provider,
metadata: record.metadata,
fetched_at: record.fetched_at,
};
if let Ok(value) = serde_json::to_value(synced) {
observed.values.insert(format!("{MATCH}{title}"), value);
}
}
}
observed
}
/// Apply each winning register here, recording it in `registers` once it
/// took. One that fails is left out, so the next sync tries it again.
fn apply(&self, registers: &mut Registers, winners: Registers) -> ProfileChanges {
let mut changes = ProfileChanges::default();
for (key, register) in winners {
match self.apply_one(&key, register.value.as_ref(), &mut changes) {
Ok(()) => {
registers.insert(key, register);
}
Err(error) => tracing::warn!(%error, key, "a synced change was not applied"),
}
}
changes
}
fn apply_one(
&self,
key: &str,
value: Option<&Value>,
changes: &mut ProfileChanges,
) -> Result<()> {
if let Some(id) = key.strip_prefix(SHARE) {
return self.apply_share(id, value, changes);
}
if let Some(title) = key.strip_prefix(TRACKS) {
if let Some(value) = value {
let prefs: TitleTrackPrefs = parse(value)?;
self.catalog.lock().set_title_track_prefs(title, &prefs)?;
changes.titles += 1;
}
return Ok(());
}
if let Some(title) = key.strip_prefix(MATCH) {
if let Some(value) = value {
let synced: SyncedMatch = parse(value)?;
self.catalog.lock().set_metadata(&MetadataRecord {
title_key: title.to_owned(),
provider: synced.provider,
metadata: synced.metadata,
fetched_at: synced.fetched_at,
manual: true,
})?;
changes.titles += 1;
}
return Ok(());
}
if key == TMDB_KEY {
match value.and_then(Value::as_str) {
Some(secret) => self.store.secrets.set(&tmdb_secret(), secret)?,
None => self.store.secrets.delete(&tmdb_secret())?,
}
changes.settings = true;
return Ok(());
}
if let Some((_, file, local)) = SETTINGS.iter().find(|(name, ..)| *name == key) {
let Some(Value::Object(incoming)) = value else {
return Ok(());
};
let path = self.dirs().config.join(file);
let before = read_json::<Value>(&path)?;
let mut merged = match &before {
Some(Value::Object(existing)) => existing.clone(),
_ => serde_json::Map::new(),
};
for (field, field_value) in incoming {
if !local.contains(&field.as_str()) {
merged.insert(field.clone(), field_value.clone());
}
}
let merged = Value::Object(merged);
if key == METADATA_SETTINGS {
self.metadata_settings_changing(before.as_ref(), &merged)?;
}
write_json(&path, &merged)?;
changes.settings = true;
return Ok(());
}
// A register a newer build writes. Kept, so it travels on.
Ok(())
}
fn apply_share(
&self,
id: &str,
value: Option<&Value>,
changes: &mut ProfileChanges,
) -> Result<()> {
let existing = self.store.list()?.into_iter().find(|share| share.id == id);
let Some(value) = value else {
if existing.is_some() {
self.store.remove(id)?;
changes.shares_removed.push(id.to_owned());
}
return Ok(());
};
let synced: SyncedShare = parse(value)?;
let expected = match (&synced.folder, &synced.url) {
(Some(folder), _) => folder.share_id(),
(None, Some(_)) => format!("share-{}", synced.token),
(None, None) => return Err(invalid("a synced link has no URL")),
};
if expected != id {
return Err(invalid("a synced share's id does not match what it opens"));
}
let secrets = synced.url.map(|url| ShareSecrets {
url,
custom_password: synced.custom_password,
});
let share = Share {
id: id.to_owned(),
name: synced.name,
token: synced.token,
has_custom_password: secrets
.as_ref()
.is_some_and(|secrets| secrets.custom_password.is_some()),
folder: synced.folder,
};
if existing.as_ref() == Some(&share)
&& secrets.as_ref().is_none_or(|secrets| {
self.store
.secrets_of(id)
.is_ok_and(|stored| &stored == secrets)
})
{
return Ok(());
}
self.store.put_synced(share, secrets.as_ref())?;
if existing.is_some() {
changes.shares_changed.push(id.to_owned());
} else {
changes.shares_added.push(id.to_owned());
}
Ok(())
}
/// Turning enrichment off or switching provider drops every stored answer
/// on the device that did it, which the settings page also does here.
fn metadata_settings_changing(&self, before: Option<&Value>, after: &Value) -> Result<()> {
let config = |value: Option<&Value>| -> MetadataConfig {
value
.and_then(|value| serde_json::from_value(value.clone()).ok())
.unwrap_or_default()
};
let (before, after) = (config(before), config(Some(after)));
if !after.enabled || before.provider != after.provider {
self.catalog.lock().clear_metadata()?;
}
Ok(())
}
}
/// Record what changed on this device since the last sync, stamped `now`.
fn stamp(registers: &mut Registers, observed: Observed, now: i64) {
for (key, register) in registers.iter_mut() {
if register.value.is_some()
&& key.starts_with(SHARE)
&& observed.shares_listed
&& !observed.values.contains_key(key)
&& !observed.unknown.contains(key)
{
*register = Register {
value: None,
updated_at: now,
};
}
}
for (key, value) in observed.values {
let unchanged = registers
.get(&key)
.is_some_and(|register| same(&key, register.value.as_ref(), Some(&value)));
if !unchanged {
registers.insert(
key,
Register {
value: Some(value),
updated_at: now,
},
);
}
}
}
/// Whether two values of one register are the same choice.
///
/// A match is compared by what it points at: its stored details move every
/// time the provider is asked again, and a register re-stamped by every
/// refresh would bounce between devices for ever.
fn same(key: &str, a: Option<&Value>, b: Option<&Value>) -> bool {
if key.starts_with(MATCH) {
let pointer = |value: Option<&Value>| {
value.map(|value| {
(
value.get("provider").cloned(),
value
.get("metadata")
.and_then(|metadata| metadata.get("remote_id"))
.cloned(),
)
})
};
return pointer(a) == pointer(b);
}
a == b
}
/// The registers from other devices that are newer than this one's.
fn merge<'r>(local: &Registers, remote: impl IntoIterator<Item = &'r Registers>) -> Registers {
let mut winners = Registers::new();
for registers in remote {
for (key, register) in registers {
let current = winners.get(key).or_else(|| local.get(key));
if current.is_none_or(|current| newer(register, current)) {
winners.insert(key.clone(), register.clone());
}
}
}
winners
}
/// Newer wins; a tie between two different values goes the same way on every
/// device, or two of them would each keep their own.
fn newer(candidate: &Register, current: &Register) -> bool {
if candidate.updated_at != current.updated_at {
return candidate.updated_at > current.updated_at;
}
let order = |register: &Register| {
register
.value
.as_ref()
.map(Value::to_string)
.unwrap_or_default()
};
order(candidate) > order(current)
}
/// `value` without the fields that stay on this device.
fn without(value: Value, local: &[&str]) -> Value {
match value {
Value::Object(mut fields) => {
for field in local {
fields.remove(*field);
}
Value::Object(fields)
}
other => other,
}
}
fn parse<T: serde::de::DeserializeOwned>(value: &Value) -> Result<T> {
serde_json::from_value(value.clone()).map_err(|e| invalid(&e.to_string()))
}
fn invalid(reason: &str) -> crate::Error {
crate::Error::Config(format!("synced setting: {reason}"))
}
#[cfg(test)]
pub(crate) mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use super::*;
use crate::shares::SecretStore;
#[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(())
}
}
/// One installation's configuration and catalog.
pub(crate) struct Device {
root: PathBuf,
pub store: ShareStore,
pub catalog: Mutex<Catalog>,
}
impl Device {
pub(crate) fn new(tag: &str) -> Self {
let mut bytes = [0_u8; 8];
getrandom::fill(&mut bytes).unwrap();
let unique: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
let root = std::env::temp_dir().join(format!("pstr-profile-{tag}-{unique}"));
let dirs = AppDirs::from_paths(root.join("c"), root.join("d"), root.join("k")).unwrap();
Self {
root,
store: ShareStore::with_secret_store(dirs, Arc::new(MemorySecrets::default())),
catalog: Mutex::new(Catalog::in_memory().unwrap()),
}
}
pub(crate) fn lock(&self) -> parking_lot::MutexGuard<'_, Catalog> {
self.catalog.lock()
}
pub(crate) fn profile(&self) -> Profile<'_> {
Profile {
store: &self.store,
catalog: &self.catalog,
}
}
fn sync(&self, remote: &[&Registers], now: i64) -> (Registers, ProfileChanges) {
self.profile().sync(remote.iter().copied(), now).unwrap()
}
fn share_names(&self) -> Vec<String> {
self.store
.list()
.unwrap()
.into_iter()
.map(|share| share.name)
.collect()
}
}
impl Drop for Device {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
const LINK: &str = "https://drive.proton.me/urls/ABCDEFGHIJ#secretpart";
#[test]
fn a_share_added_on_one_device_arrives_on_another_with_its_secrets() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.sync(&[], 10);
laptop.store.add("Anime", LINK, Some("hunter2")).unwrap();
let (sent, _) = laptop.sync(&[], 20);
let (_, changes) = phone.sync(&[&sent], 30);
assert_eq!(changes.shares_added, ["share-ABCDEFGHIJ"]);
assert_eq!(phone.share_names(), ["Anime"]);
let secrets = phone.store.secrets_of("share-ABCDEFGHIJ").unwrap();
assert_eq!(secrets.url, LINK);
assert_eq!(secrets.custom_password.as_deref(), Some("hunter2"));
}
#[test]
fn a_share_removed_on_one_device_is_removed_on_the_others() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.store.add("Anime", LINK, None).unwrap();
let (sent, _) = laptop.sync(&[], 10);
let (phone_sent, _) = phone.sync(&[&sent], 20);
laptop.store.remove("share-ABCDEFGHIJ").unwrap();
let (sent, _) = laptop.sync(&[&phone_sent], 30);
let (_, changes) = phone.sync(&[&sent], 40);
assert_eq!(changes.shares_removed, ["share-ABCDEFGHIJ"]);
assert!(phone.share_names().is_empty());
}
#[test]
fn a_fresh_installation_takes_the_library_rather_than_its_defaults() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.sync(&[], 10);
let mut prefs = crate::prefs::load(&laptop.store.dirs).unwrap();
prefs.autoplay_next = false;
prefs.speed = 1.5;
crate::prefs::save(&laptop.store.dirs, &prefs).unwrap();
let (sent, _) = laptop.sync(&[], 20);
// The new phone has written its defaults before it ever synced.
crate::prefs::save(&phone.store.dirs, &crate::prefs::PlaybackPrefs::default()).unwrap();
let (_, changes) = phone.sync(&[&sent], 30);
assert!(changes.settings);
let taken = crate::prefs::load(&phone.store.dirs).unwrap();
assert!(!taken.autoplay_next);
assert_eq!(taken.speed, 1.5);
}
#[test]
fn volume_and_mute_stay_on_the_device_that_set_them() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.sync(&[], 10);
let mut prefs = crate::prefs::PlaybackPrefs {
volume: 20.0,
muted: true,
..Default::default()
};
crate::prefs::save(&laptop.store.dirs, &prefs).unwrap();
let (sent, _) = laptop.sync(&[], 20);
prefs.volume = 80.0;
prefs.muted = false;
prefs.subtitles = false;
crate::prefs::save(&phone.store.dirs, &prefs).unwrap();
phone.sync(&[], 5);
phone.sync(&[&sent], 30);
let taken = crate::prefs::load(&phone.store.dirs).unwrap();
assert_eq!(taken.volume, 80.0);
assert!(!taken.muted);
assert!(taken.subtitles, "the laptop's later choice wins");
}
#[test]
fn the_newer_change_wins_whichever_device_syncs_first() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.sync(&[], 1);
phone.sync(&[], 1);
let mut appearance = crate::appearance::load(&laptop.store.dirs).unwrap();
appearance.gradients = false;
crate::appearance::save(&laptop.store.dirs, &appearance).unwrap();
let (from_laptop, _) = laptop.sync(&[], 10);
appearance.gradients = true;
appearance.accent = crate::appearance::Accent::Peach;
crate::appearance::save(&phone.store.dirs, &appearance).unwrap();
let (from_phone, _) = phone.sync(&[&from_laptop], 20);
laptop.sync(&[&from_phone], 30);
for device in [&laptop, &phone] {
let seen = crate::appearance::load(&device.store.dirs).unwrap();
assert_eq!(seen.accent, crate::appearance::Accent::Peach);
assert!(seen.gradients);
}
}
#[test]
fn an_unchanged_device_sends_the_same_registers_again() {
let laptop = Device::new("laptop");
laptop.store.add("Anime", LINK, None).unwrap();
let (first, _) = laptop.sync(&[], 10);
let (second, changes) = laptop.sync(&[&first], 20);
assert_eq!(first, second);
assert!(changes.is_empty());
}
#[test]
fn a_share_whose_secret_cannot_be_read_is_not_removed_elsewhere() {
let laptop = Device::new("laptop");
laptop.store.add("Anime", LINK, None).unwrap();
laptop.sync(&[], 10);
laptop.store.secrets.delete("share-ABCDEFGHIJ").unwrap();
let (sent, _) = laptop.sync(&[], 20);
assert!(sent["share/share-ABCDEFGHIJ"].value.is_some());
}
#[test]
fn a_hand_picked_match_and_a_shows_track_choice_travel() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.sync(&[], 1);
let metadata = TitleMetadata {
provider: ProviderId::AniList,
remote_id: "21".to_owned(),
name: "One Piece".to_owned(),
original_name: None,
overview: None,
year: Some(1999),
kind: crate::library::TitleKind::Series,
poster_url: None,
backdrop_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
url: None,
details: None,
};
laptop
.catalog
.lock()
.set_metadata(&MetadataRecord {
title_key: "one piece".to_owned(),
provider: ProviderId::AniList,
metadata: Some(metadata),
fetched_at: 5,
manual: true,
})
.unwrap();
let tracks = TitleTrackPrefs {
audio_language: Some("jpn".to_owned()),
..Default::default()
};
laptop
.catalog
.lock()
.set_title_track_prefs("one piece", &tracks)
.unwrap();
let (sent, _) = laptop.sync(&[], 10);
let (_, changes) = phone.sync(&[&sent], 20);
assert_eq!(changes.titles, 2);
let catalog = phone.catalog.lock();
let record = catalog.metadata("one piece").unwrap().unwrap();
assert!(record.manual);
assert_eq!(record.metadata.unwrap().remote_id, "21");
assert_eq!(
catalog.title_track_prefs("one piece").unwrap(),
Some(tracks)
);
}
#[test]
fn a_synced_share_that_names_another_id_is_refused() {
let phone = Device::new("phone");
let mut forged = Registers::new();
forged.insert(
"share/share-OTHER".to_owned(),
Register {
value: Some(serde_json::json!({
"name": "x", "token": "ABCDEFGHIJ", "url": LINK
})),
updated_at: 10,
},
);
let (registers, changes) = phone.sync(&[&forged], 20);
assert!(changes.shares_added.is_empty());
assert!(!registers.contains_key("share/share-OTHER"));
}
}
+68 -20
View File
@@ -355,19 +355,19 @@ impl AccountFolder {
}
/// The secrets for one share, as held in the credential store.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ShareSecrets {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct ShareSecrets {
/// The full share URL, fragment included.
url: String,
pub(crate) url: String,
/// The custom password, when the link has one.
#[serde(default)]
custom_password: Option<String>,
pub(crate) custom_password: Option<String>,
}
/// The configured shares, and their secrets.
pub struct ShareStore {
dirs: AppDirs,
secrets: Arc<dyn SecretStore>,
pub(crate) dirs: AppDirs,
pub(crate) secrets: Arc<dyn SecretStore>,
}
impl ShareStore {
@@ -396,9 +396,9 @@ impl ShareStore {
let mut shares = self.list()?;
if shares.iter().any(|share| share.id == id) {
return Err(Error::Config(format!(
"a share for token {token} is already configured"
)));
return Err(Error::Config(
"that link is already in the library".to_owned(),
));
}
let secrets = ShareSecrets {
@@ -421,6 +421,53 @@ impl ShareStore {
Ok(share)
}
/// [`Self::add`], but only once the link has been opened with the
/// password given.
///
/// A link that is mistyped, expired or behind the wrong password used to
/// be stored anyway, so the first try failed on the crawl and every retry
/// failed on "already configured" — the only way out was to remove a share
/// that had never worked. Opening first means a refused link leaves
/// nothing behind, and the client it opened is handed back for the crawl.
pub async fn add_verified(
&self,
name: &str,
url: &str,
custom_password: Option<&str>,
) -> Result<(Share, ShareClient)> {
let token = token_from_url(url)?;
let id = format!("share-{token}");
if self.list()?.iter().any(|share| share.id == id) {
return Err(Error::Config(
"that link is already in the library".to_owned(),
));
}
let custom_password = custom_password.filter(|password| !password.is_empty());
let client = open_link(url, custom_password).await?;
let share = self.add(name, url, custom_password)?;
Ok((share, client))
}
/// Record a share another installation has, with the secrets it opens
/// with, replacing what this one holds under the same id. See
/// [`crate::profile`].
pub(crate) fn put_synced(&self, share: Share, secrets: Option<&ShareSecrets>) -> Result<()> {
if let Some(secrets) = secrets {
self.store_secrets(&share.id, secrets)?;
}
let mut shares = self.list()?;
match shares.iter_mut().find(|existing| existing.id == share.id) {
Some(existing) => *existing = share,
None => shares.push(share),
}
write_json(&self.dirs.shares_file(), &shares)
}
/// A share's secrets, for syncing them to another installation.
pub(crate) fn secrets_of(&self, id: &str) -> Result<ShareSecrets> {
self.load_secrets(id)
}
/// Record a folder of the signed-in account as a share.
///
/// Nothing goes in the credential store: the folder opens through the
@@ -526,17 +573,7 @@ impl ShareStore {
});
}
let secrets = self.load_secrets(&share.id)?;
let client = ProtonDrivePublicLinkClient::open(
client_configuration(),
&secrets.url,
secrets.custom_password.as_deref(),
)
.await?
// The SDK default is sized for a background sync daemon. A player that
// 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(ShareClient::PublicLink(Box::new(client)))
open_link(&secrets.url, secrets.custom_password.as_deref()).await
}
fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> {
@@ -556,6 +593,17 @@ impl ShareStore {
}
}
/// Open a public link as a visitor.
async fn open_link(url: &str, custom_password: Option<&str>) -> Result<ShareClient> {
let client = ProtonDrivePublicLinkClient::open(client_configuration(), url, custom_password)
.await?
// The SDK default is sized for a background sync daemon. A player that
// 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(ShareClient::PublicLink(Box::new(client)))
}
/// Several opened shares, presented as one library.
pub struct SharedLibrary {
clients: BTreeMap<String, ShareClient>,
+151 -74
View File
@@ -1,5 +1,7 @@
//! Watch history kept in the viewer's own Drive, so every device resumes where
//! another left off.
//! another left off — and, in the same file, the shares, settings and
//! hand-picked matches that make the library look the same on every device
//! (see [`crate::profile`]).
//!
//! ## Layout
//!
@@ -17,7 +19,8 @@
//! 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
//! episode: the newer `updated_at` wins. The profile's registers merge the same
//! way, per key. 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
@@ -25,11 +28,11 @@
//!
//! 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.
//! the same `(share_id, link_id)` on both. The watch entries carry nothing
//! secret; the profile's share registers do carry each link and its password,
//! which is why they live only here, end-to-end encrypted by Drive.
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use parking_lot::Mutex;
use proton_drive_rs::{DeviceType, NodeKind, ProtonDriveClient};
@@ -40,6 +43,8 @@ use crate::account::Account;
use crate::catalog::{Catalog, WatchState};
use crate::config::{AppDirs, read_json, write_json};
use crate::error::{Error, Result};
use crate::profile::{Profile, ProfileChanges, Registers};
use crate::shares::ShareStore;
/// The Drive device watch history is kept on.
pub const DEVICE_NAME: &str = "proton-stream";
@@ -48,7 +53,8 @@ pub const DEVICE_NAME: &str = "proton-stream";
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.
/// version is skipped rather than half-understood. The profile's registers
/// were added without a bump: a build that predates them ignores the field.
const FORMAT_VERSION: u32 = 1;
/// One installation's watch history, as stored in Drive.
@@ -57,6 +63,15 @@ struct HistoryFile {
version: u32,
installation: String,
entries: Vec<HistoryEntry>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
registers: Registers,
}
/// What one history file holds.
#[derive(Debug, Clone, Default, PartialEq)]
struct Contents {
entries: Vec<HistoryEntry>,
registers: Registers,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -112,7 +127,7 @@ struct Identity {
}
/// What one [`WatchSync::sync`] did.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SyncReport {
/// Episodes whose position another device had newer.
pub applied: usize,
@@ -123,6 +138,8 @@ pub struct SyncReport {
/// Files of installations gone quiet that were moved to the trash, their
/// history now carried in this installation's own file.
pub retired: usize,
/// What other devices' shares and settings changed here.
pub profile: ProfileChanges,
}
/// How long an installation's file may go unwritten before another
@@ -243,10 +260,10 @@ impl HistoryDrive for ProtonDriveClient {
}
/// A history file as last read, by the revision it was read at. `None`
/// entries for one this build could not read.
/// contents for one this build could not read.
struct Seen {
revision: Option<String>,
entries: Option<Vec<HistoryEntry>>,
contents: Option<Contents>,
}
/// Syncs the catalog's watch table with the account's Drive.
@@ -257,8 +274,9 @@ pub struct WatchSync {
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 catalog and the profile so an unchanged history is not uploaded as
/// a new revision.
own: Mutex<Option<Contents>>,
/// The history folders, resolved once per account. Usually one; see
/// [`HistoryDrive::history_folders`].
folders: Mutex<Option<Vec<NodeUid>>>,
@@ -303,25 +321,34 @@ impl WatchSync {
*self.folders.lock() = None;
}
/// Pull every other installation's history into `catalog`, write this
/// one's if it changed, then retire the files of installations gone quiet.
/// Pull every other installation's history, shares and settings in, write
/// this one's if it changed, then retire the files of installations gone
/// quiet.
///
/// 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> {
/// runs. Shares that arrive are configured but not opened or crawled; see
/// [`SyncReport::profile`] for what the front end has to do about them.
pub async fn sync(
&self,
account: &Account,
catalog: &Mutex<Catalog>,
store: &ShareStore,
) -> Result<SyncReport> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_secs() as i64);
self.sync_with(account.drive(), catalog, now).await
let profile = Profile { store, catalog };
self.sync_with(account.drive(), &profile, now).await
}
pub(crate) async fn sync_with<D: HistoryDrive>(
&self,
drive: &D,
catalog: &Mutex<Catalog>,
profile: &Profile<'_>,
now: i64,
) -> Result<SyncReport> {
let catalog = profile.catalog;
let _running = self.running.lock().await;
let folders = self.folders(drive).await?;
let own_name = format!("{}.json", self.installation);
@@ -339,12 +366,14 @@ impl WatchSync {
}
let mut remote = Vec::new();
let mut registers = Vec::new();
// Files whose every entry has been read, and so can be retired once
// this installation's file carries them.
let mut readable = Vec::new();
for file in &others {
if let Some(entries) = self.read(drive, file).await {
remote.extend(entries);
if let Some(contents) = self.read(drive, file).await {
remote.extend(contents.entries);
registers.push(contents.registers);
readable.push(file);
}
}
@@ -355,19 +384,22 @@ impl WatchSync {
// less than it holds, and read again next time.
let mut own_unreadable = false;
if self.own.lock().is_none() {
let entries = match &own_file {
let contents = match &own_file {
Some(file) => self.read(drive, file).await,
None => Some(Vec::new()),
None => Some(Contents::default()),
};
match entries {
Some(entries) => {
remote.extend(entries.iter().cloned());
*self.own.lock() = Some(entries);
match contents {
Some(contents) => {
remote.extend(contents.entries.iter().cloned());
registers.push(contents.registers.clone());
*self.own.lock() = Some(contents);
}
None => own_unreadable = true,
}
}
let (registers, profile_changes) = profile.sync(&registers, now)?;
let incoming: Vec<(String, String, WatchState)> = remote
.iter()
.filter(|entry| entry.is_sane())
@@ -388,15 +420,17 @@ impl WatchSync {
// A file that is missing is written even when what it would hold has
// not changed: another installation retired it, or it was deleted by
// hand, and either way the history it carried lives only here now.
let unchanged = own_file.is_some() && self.own.lock().as_ref() == Some(&entries);
let uploaded = if own_unreadable || unchanged || (entries.is_empty() && own_file.is_none())
{
let contents = Contents { entries, registers };
let unchanged = own_file.is_some() && self.own.lock().as_ref() == Some(&contents);
let empty = contents.entries.is_empty() && contents.registers.is_empty();
let uploaded = if own_unreadable || unchanged || (empty && own_file.is_none()) {
false
} else {
let file = HistoryFile {
version: FORMAT_VERSION,
installation: self.installation.clone(),
entries: entries.clone(),
entries: contents.entries.clone(),
registers: contents.registers.clone(),
};
let bytes = serde_json::to_vec(&file)
.map_err(|e| Error::Config(format!("serialize watch history: {e}")))?;
@@ -404,7 +438,7 @@ impl WatchSync {
Some(file) => drive.replace(&file.uid, &bytes).await?,
None => drive.create(&folders[0], &own_name, &bytes).await?,
}
*self.own.lock() = Some(entries);
*self.own.lock() = Some(contents);
true
};
@@ -435,34 +469,31 @@ impl WatchSync {
uploaded,
devices: others.len(),
retired: retired.len(),
profile: profile_changes,
})
}
/// The entries of one history file, from the cache when its revision has
/// The contents of one history file, from the cache when its revision has
/// not moved, or `None` when it cannot be read.
///
/// A file that will not download or parse is logged and skipped: it is
/// another device's, and one bad file must not stop the rest syncing.
async fn read<D: HistoryDrive>(
&self,
drive: &D,
file: &RemoteFile,
) -> Option<Vec<HistoryEntry>> {
async fn read<D: HistoryDrive>(&self, drive: &D, file: &RemoteFile) -> Option<Contents> {
let key = file.uid.link_id.as_str().to_owned();
if let Some(seen) = self.seen.lock().get(&key)
&& file.revision.is_some()
&& seen.revision == file.revision
{
return seen.entries.clone();
return seen.contents.clone();
}
let uid = &file.uid;
let entries = match drive.download(uid).await {
let contents = match drive.download(uid).await {
Ok(bytes) => {
let entries = parse(&bytes);
if entries.is_none() {
let contents = parse(&bytes);
if contents.is_none() {
tracing::warn!(%uid, "skipping a watch-history file this build cannot read");
}
entries
contents
}
Err(error) => {
// Not cached: a download that failed is tried again next time.
@@ -474,10 +505,10 @@ impl WatchSync {
key,
Seen {
revision: file.revision.clone(),
entries: entries.clone(),
contents: contents.clone(),
},
);
entries
contents
}
/// The history folders, resolved once per account.
@@ -491,10 +522,13 @@ impl WatchSync {
}
}
/// A history file's entries, or `None` for one this build cannot read.
fn parse(bytes: &[u8]) -> Option<Vec<HistoryEntry>> {
/// A history file's contents, or `None` for one this build cannot read.
fn parse(bytes: &[u8]) -> Option<Contents> {
let file: HistoryFile = serde_json::from_slice(bytes).ok()?;
(file.version <= FORMAT_VERSION).then_some(file.entries)
(file.version <= FORMAT_VERSION).then_some(Contents {
entries: file.entries,
registers: file.registers,
})
}
/// The kind of device to register, which is only the icon the web app draws.
@@ -518,6 +552,7 @@ fn new_installation_id() -> Result<String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::profile::tests::Device;
fn entry(link: &str, position: f64, updated_at: i64) -> HistoryEntry {
HistoryEntry {
@@ -655,11 +690,8 @@ mod tests {
}
/// An installation: its sync and its catalog.
fn device(id: &str) -> (WatchSync, Mutex<Catalog>) {
(
WatchSync::with_installation(id.to_owned()),
Mutex::new(Catalog::in_memory().unwrap()),
)
fn device(id: &str) -> (WatchSync, Device) {
(WatchSync::with_installation(id.to_owned()), Device::new(id))
}
fn watched_at(position_secs: f64, updated_at: i64) -> WatchState {
@@ -684,11 +716,14 @@ mod tests {
.unwrap();
let first = laptop
.sync_with(&drive, &laptop_catalog, 100)
.sync_with(&drive, &laptop_catalog.profile(), 100)
.await
.unwrap();
assert!(first.uploaded);
let second = phone.sync_with(&drive, &phone_catalog, 110).await.unwrap();
let second = phone
.sync_with(&drive, &phone_catalog.profile(), 110)
.await
.unwrap();
assert_eq!(second.applied, 1);
assert_eq!(second.devices, 1);
@@ -714,12 +749,15 @@ mod tests {
.unwrap();
laptop
.sync_with(&drive, &laptop_catalog, 300)
.sync_with(&drive, &laptop_catalog.profile(), 300)
.await
.unwrap();
phone
.sync_with(&drive, &phone_catalog.profile(), 300)
.await
.unwrap();
phone.sync_with(&drive, &phone_catalog, 300).await.unwrap();
laptop
.sync_with(&drive, &laptop_catalog, 300)
.sync_with(&drive, &laptop_catalog.profile(), 300)
.await
.unwrap();
@@ -740,8 +778,14 @@ mod tests {
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
.unwrap();
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
let again = laptop.sync_with(&drive, &catalog, 200).await.unwrap();
laptop
.sync_with(&drive, &catalog.profile(), 100)
.await
.unwrap();
let again = laptop
.sync_with(&drive, &catalog.profile(), 200)
.await
.unwrap();
assert!(!again.uploaded);
assert_eq!(drive.uploads(), 1);
@@ -751,7 +795,10 @@ mod tests {
async fn nothing_is_uploaded_before_anything_was_watched() {
let drive = FakeDrive::default();
let (laptop, catalog) = device("laptop");
let report = laptop.sync_with(&drive, &catalog, 100).await.unwrap();
let report = laptop
.sync_with(&drive, &catalog.profile(), 100)
.await
.unwrap();
assert!(!report.uploaded);
assert!(drive.names().is_empty());
}
@@ -764,11 +811,17 @@ mod tests {
.lock()
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
.unwrap();
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
laptop
.sync_with(&drive, &catalog.profile(), 100)
.await
.unwrap();
let own: Vec<NodeUid> = drive.files.lock().iter().map(|f| f.uid.clone()).collect();
drive.trash(&own).await.unwrap();
let report = laptop.sync_with(&drive, &catalog, 200).await.unwrap();
let report = laptop
.sync_with(&drive, &catalog.profile(), 200)
.await
.unwrap();
assert!(report.uploaded);
assert_eq!(drive.names(), ["laptop.json"]);
@@ -782,11 +835,17 @@ mod tests {
.lock()
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
.unwrap();
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
laptop
.sync_with(&drive, &catalog.profile(), 100)
.await
.unwrap();
// The catalog was deleted; the config, and with it the id, was not.
let (relaunched, empty) = device("laptop");
let report = relaunched.sync_with(&drive, &empty, 200).await.unwrap();
let report = relaunched
.sync_with(&drive, &empty.profile(), 200)
.await
.unwrap();
assert_eq!(report.applied, 1);
assert!(!report.uploaded);
@@ -804,7 +863,10 @@ mod tests {
.lock()
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
.unwrap();
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
laptop
.sync_with(&drive, &catalog.profile(), 100)
.await
.unwrap();
let (relaunched, other) = device("laptop");
other
@@ -814,13 +876,19 @@ mod tests {
drive
.offline
.store(true, std::sync::atomic::Ordering::SeqCst);
let report = relaunched.sync_with(&drive, &other, 200).await.unwrap();
let report = relaunched
.sync_with(&drive, &other.profile(), 200)
.await
.unwrap();
assert!(!report.uploaded);
drive
.offline
.store(false, std::sync::atomic::Ordering::SeqCst);
relaunched.sync_with(&drive, &other, 300).await.unwrap();
relaunched
.sync_with(&drive, &other.profile(), 300)
.await
.unwrap();
let state = other.lock().all_watch_states().unwrap();
assert_eq!(state.len(), 2, "both episodes survive: {state:?}");
}
@@ -832,6 +900,7 @@ mod tests {
version: FORMAT_VERSION + 1,
installation: "future".to_owned(),
entries: vec![entry("ep1", 60.0, 100)],
registers: Registers::new(),
};
drive.put("future.json", serde_json::to_vec(&future).unwrap());
let (laptop, catalog) = device("laptop");
@@ -841,7 +910,7 @@ mod tests {
.unwrap();
let report = laptop
.sync_with(&drive, &catalog, RETIRE_AFTER_SECS + DAY)
.sync_with(&drive, &catalog.profile(), RETIRE_AFTER_SECS + DAY)
.await
.unwrap();
@@ -860,21 +929,24 @@ mod tests {
.unwrap();
drive.at(100);
old_phone
.sync_with(&drive, &old_catalog, 100)
.sync_with(&drive, &old_catalog.profile(), 100)
.await
.unwrap();
let (phone, catalog) = device("phone");
let later = 100 + RETIRE_AFTER_SECS;
drive.at(later);
let report = phone.sync_with(&drive, &catalog, later).await.unwrap();
let report = phone
.sync_with(&drive, &catalog.profile(), later)
.await
.unwrap();
assert_eq!(report.retired, 1);
assert_eq!(drive.names(), ["phone.json"]);
// Nothing was lost: the newcomer's file carries it now.
let (fresh, fresh_catalog) = device("fresh");
fresh
.sync_with(&drive, &fresh_catalog, later)
.sync_with(&drive, &fresh_catalog.profile(), later)
.await
.unwrap();
assert_eq!(
@@ -893,13 +965,13 @@ mod tests {
.unwrap();
drive.at(100);
laptop
.sync_with(&drive, &laptop_catalog, 100)
.sync_with(&drive, &laptop_catalog.profile(), 100)
.await
.unwrap();
let (phone, catalog) = device("phone");
let report = phone
.sync_with(&drive, &catalog, 100 + RETIRE_AFTER_SECS - DAY)
.sync_with(&drive, &catalog.profile(), 100 + RETIRE_AFTER_SECS - DAY)
.await
.unwrap();
@@ -925,9 +997,13 @@ mod tests {
version: FORMAT_VERSION,
installation: "abc".to_owned(),
entries: vec![entry("a", 12.5, 100)],
registers: Registers::new(),
};
let bytes = serde_json::to_vec(&file).unwrap();
assert_eq!(parse(&bytes), Some(file.entries));
assert_eq!(
parse(&bytes).map(|contents| contents.entries),
Some(file.entries)
);
}
#[test]
@@ -936,6 +1012,7 @@ mod tests {
version: FORMAT_VERSION + 1,
installation: "abc".to_owned(),
entries: vec![entry("a", 12.5, 100)],
registers: Registers::new(),
};
assert_eq!(parse(&serde_json::to_vec(&file).unwrap()), None);
assert_eq!(parse(b"not json"), None);