mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
fix(core,app,android,cli,packaging): harden account sign-in and watch-history sync
- Desktop shows Proton's CAPTCHA instead of failing the sign-in: a wry webview in a child process of the app (`pstr-app::verify`), confined to https://*.proton.me, hands the token back and the sign-in is retried. Linux packages and CI gain WebKitGTK 4.1; the Windows zip and MSI ship WebView2Loader.dll, without which a -gnu build does not start. - The desktop keyring splits a secret too long for one entry across several, so a Proton session with many key salts survives Windows' 1280-unit cap. Short secrets stay one entry, so every existing secret still reads. - `WatchSync` runs against a small `HistoryDrive` trait and is tested end to end against an in-memory Drive: two devices, newer wins, no re-upload of an unchanged history, a missing file written again, an unreadable own file never overwritten, a newer-format file left alone. - An installation's file unwritten for 90 days is retired by the next device to sync, after its entries are merged and carried in that device's file. - Removing a share keeps its watch state: sync would bring it back from the other devices anyway, and re-adding the share picks up where it left off. - Watch history syncs once more on desktop exit (bounded to 3 s) and, on Android, through a WorkManager job enqueued when the app leaves the screen. - The second-factor prompts say security keys are not supported.
This commit is contained in:
1 parent
26cccd5c63
commit
d9ca8f8d60
28 files changed
+2466
-177
No files matched your search
@@ -23,11 +23,11 @@ jobs:
|
||||
|
||||
- uses: Swatinem/rust-cache@v2.9.1
|
||||
|
||||
# libmpv for pstr-player.
|
||||
- name: Install libmpv
|
||||
# libmpv for pstr-player, WebKitGTK for the CAPTCHA window.
|
||||
- name: Install libmpv and WebKitGTK
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libmpv-dev
|
||||
sudo apt-get install -y libmpv-dev libwebkit2gtk-4.1-dev
|
||||
|
||||
- run: cargo fmt --all -- --check
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libmpv-dev rpm
|
||||
sudo apt-get install -y libmpv-dev libwebkit2gtk-4.1-dev rpm
|
||||
|
||||
- name: Gate
|
||||
run: |
|
||||
|
||||
Generated
+1216
-38
File diff suppressed because it is too large.
Load diff
@@ -107,6 +107,12 @@ windows-sys = "0.61"
|
||||
tauri-winrt-notification = "0.8"
|
||||
# The exe's own icon resource, which is what Explorer and a taskbar pin draw.
|
||||
embed-resource = "3"
|
||||
# Proton's CAPTCHA page, which reports success by posting a message to its
|
||||
# host, so it needs a webview this app controls. Run in a child process of its
|
||||
# own (`pstr-app::verify`): tao and eframe's winit cannot share an event loop.
|
||||
# WebKitGTK on Linux, WebView2 on Windows, WKWebView on macOS.
|
||||
wry = { version = "0.57", default-features = false, features = ["os-webview"] }
|
||||
tao = { version = "0.37", default-features = false, features = ["rwh_06"] }
|
||||
rpassword = "7"
|
||||
libloading = "0.8"
|
||||
lru = "0.18"
|
||||
|
||||
@@ -83,7 +83,9 @@ Rust 2024, MSRV 1.96.
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
`pstr-player` needs libmpv development headers (`mpv` ≥ 2.0). On Windows those
|
||||
`pstr-player` needs libmpv development headers (`mpv` ≥ 2.0), and on Linux
|
||||
`pstr-app` needs WebKitGTK 4.1's, for the window Proton's CAPTCHA is shown in.
|
||||
On Windows the libmpv ones
|
||||
come from the separate **mpv-dev** archive rather than mpv's player build, and
|
||||
`libmpv-2.dll` ships next to the executable. The Proton SDK crates come from
|
||||
crates.io; nothing here builds against a sibling checkout.
|
||||
|
||||
@@ -33,6 +33,7 @@ import io.narl.protonstream.playback.MpvPlaybackState
|
||||
import io.narl.protonstream.playback.NativeMpvHost
|
||||
import io.narl.protonstream.playback.PlaybackService
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.sync.WatchSyncWorker
|
||||
import io.narl.protonstream.ui.ProtonStreamApp
|
||||
import io.narl.protonstream.ui.theme.AppearanceState
|
||||
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
||||
@@ -207,6 +208,9 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
// Where the viewer stopped is what their other devices want next, and
|
||||
// a process in the background may not live to the next timed sync.
|
||||
if (!isChangingConfigurations) WatchSyncWorker.enqueue(this)
|
||||
if (
|
||||
!isChangingConfigurations &&
|
||||
!isInPictureInPictureMode &&
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package io.narl.protonstream.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import uniffi.pstr_android.AccountState
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* One watch-history sync after the app leaves the screen.
|
||||
*
|
||||
* The model's timer only runs while the app is alive, and Android is free to
|
||||
* kill a process in the background without notice — so the position the
|
||||
* viewer stopped at would wait for the next launch to reach their other
|
||||
* devices. Handed to WorkManager instead, it goes as soon as there is a
|
||||
* network, even if the process is gone by then.
|
||||
*/
|
||||
class WatchSyncWorker(
|
||||
appContext: Context,
|
||||
parameters: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, parameters) {
|
||||
override suspend fun doWork(): Result {
|
||||
val engine = NativeRuntime.engine()
|
||||
return runCatching { engine.syncWatchHistory() }.fold(
|
||||
onSuccess = { Result.success() },
|
||||
onFailure = {
|
||||
// Signed out by the engine — the session ended — is not
|
||||
// something a retry fixes; a dropped connection is.
|
||||
val signedOut = runCatching { engine.accountState() }.getOrNull() == AccountState.SignedOut
|
||||
if (signedOut || runAttemptCount >= MAX_ATTEMPTS) Result.failure() else Result.retry()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val WORK_NAME = "watch-history-sync"
|
||||
private const val MAX_ATTEMPTS = 3
|
||||
|
||||
/** Replacing: one pending sync covers every position saved before it runs. */
|
||||
fun enqueue(context: Context) {
|
||||
val request = OneTimeWorkRequestBuilder<WatchSyncWorker>()
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
WorkManager.getInstance(context.applicationContext)
|
||||
.enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,13 +194,16 @@ internal fun SignInDialog(account: AccountUiState, actions: AccountActions, onDi
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
when (state) {
|
||||
AccountState.SecondFactor -> OutlinedTextField(
|
||||
AccountState.SecondFactor -> {
|
||||
OutlinedTextField(
|
||||
code,
|
||||
{ code = it },
|
||||
label = { Text("Two-factor code") },
|
||||
label = { Text("Code from your authenticator app") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
)
|
||||
Text(SECURITY_KEY_NOTE, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
AccountState.MailboxPassword -> {
|
||||
Text(
|
||||
"This account has a separate mailbox password. It unlocks your files.",
|
||||
@@ -329,6 +332,11 @@ private fun VerificationDialog(url: String, onToken: (String) -> Unit, onDismiss
|
||||
}
|
||||
}
|
||||
|
||||
/** Security keys need WebAuthn, which the sign-in here does not speak. */
|
||||
private const val SECURITY_KEY_NOTE =
|
||||
"Security keys are not supported. If your account has only a security key, add an " +
|
||||
"authenticator app in your Proton account settings to sign in here."
|
||||
|
||||
/** Installed once per page, at start and again at finish in case the first ran too early. */
|
||||
private const val VERIFICATION_BRIDGE = """
|
||||
if (!window.__pstrVerification) {
|
||||
|
||||
@@ -136,15 +136,15 @@ internal fun SharesScreen(
|
||||
)
|
||||
}
|
||||
removing?.let { share ->
|
||||
// Asked, because it cannot be undone: the link's secret, its offline
|
||||
// files and its watch positions all go (`Catalog::remove_share`).
|
||||
// Asked, because it cannot be undone: the link's secret and its
|
||||
// offline files go. Watch positions stay (`Catalog::remove_share`).
|
||||
AlertDialog(
|
||||
onDismissRequest = { removing = null },
|
||||
title = { Text("Remove ${share.name}?") },
|
||||
text = {
|
||||
Text(
|
||||
"Its titles leave the library, and its offline episodes and watch " +
|
||||
"progress are deleted. Adding the link again starts it fresh.",
|
||||
"Its titles leave the library and its offline episodes are deleted. " +
|
||||
"Watch progress is kept, so adding the link again picks up where you left off.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
|
||||
@@ -3809,11 +3809,12 @@ mod tests {
|
||||
assert!(engine.remove_share(share.id.clone()).is_err());
|
||||
assert_eq!(engine.share_generation.load(Ordering::Acquire), 2);
|
||||
assert!(engine.shares().expect("shares").is_empty());
|
||||
// Kept, as through a recrawl: sync would bring it back regardless.
|
||||
assert!(
|
||||
engine
|
||||
.watch_state(share.id, "episode".to_owned())
|
||||
.expect("watch state")
|
||||
.is_none()
|
||||
.is_some()
|
||||
);
|
||||
|
||||
drop(engine);
|
||||
|
||||
@@ -33,6 +33,8 @@ serde_json.workspace = true
|
||||
directories.workspace = true
|
||||
chrono.workspace = true
|
||||
souvlaki.workspace = true
|
||||
wry.workspace = true
|
||||
tao.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
embed-resource.workspace = true
|
||||
|
||||
@@ -2118,6 +2118,9 @@ impl eframe::App for App {
|
||||
self.playback = None;
|
||||
self.engine
|
||||
.shutdown_downloads(std::time::Duration::from_secs(3));
|
||||
// After the player: its last position is saved on the way out.
|
||||
self.engine
|
||||
.sync_before_exit(std::time::Duration::from_secs(3));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ use pstr_core::library::{Library, Title, TitleKind};
|
||||
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId};
|
||||
use pstr_core::prefs::PlaybackPrefs;
|
||||
use pstr_core::proton_drive_rs::ThumbnailType;
|
||||
use pstr_core::proton_sdk::api::HumanVerificationCredential;
|
||||
use pstr_core::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||
use pstr_core::shares::AccountFolder;
|
||||
use pstr_core::sync::{SyncReport, WatchSync};
|
||||
@@ -704,17 +705,38 @@ impl Engine {
|
||||
pub fn sign_in(&self, username: String, password: String) {
|
||||
let engine = self.clone();
|
||||
self.runtime.spawn(async move {
|
||||
match engine.accounts.sign_in(&username, &password, None).await {
|
||||
Ok(step) => engine.sign_in_step(step),
|
||||
Err(pstr_core::Error::HumanVerification(_)) => {
|
||||
engine.emit(Event::SignInFailed(
|
||||
"Proton wants a CAPTCHA before it accepts this sign-in, and this app \
|
||||
cannot show one. Try again later, or sign in once from the Android app."
|
||||
let mut verification = None;
|
||||
// Twice at most: once as asked, and once more with a solved
|
||||
// CAPTCHA if Proton wanted one. A second gate after that is
|
||||
// Proton refusing the answer, not a puzzle worth repeating.
|
||||
for _ in 0..2 {
|
||||
match engine
|
||||
.accounts
|
||||
.sign_in(&username, &password, verification.take())
|
||||
.await
|
||||
{
|
||||
Ok(step) => return engine.sign_in_step(step),
|
||||
Err(pstr_core::Error::HumanVerification(challenge)) => {
|
||||
let url = challenge.verification_url();
|
||||
let token = tokio::task::spawn_blocking(move || crate::verify::solve(&url))
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let Some(token) = token else {
|
||||
return engine.emit(Event::SignInFailed(
|
||||
"Proton wants you to confirm you are human before it accepts \
|
||||
this sign-in. Sign in again to solve the puzzle."
|
||||
.to_owned(),
|
||||
));
|
||||
};
|
||||
verification = Some(HumanVerificationCredential::captcha(token));
|
||||
}
|
||||
Err(error) => engine.emit(Event::SignInFailed(error.to_string())),
|
||||
Err(error) => return engine.emit(Event::SignInFailed(error.to_string())),
|
||||
}
|
||||
}
|
||||
engine.emit(Event::SignInFailed(
|
||||
"Proton did not accept the verification. Try again in a while.".to_owned(),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -841,6 +863,27 @@ impl Engine {
|
||||
});
|
||||
}
|
||||
|
||||
/// Sync once more before the app exits, waiting at most `timeout`.
|
||||
///
|
||||
/// Where the viewer stopped is what another device wants next, and the
|
||||
/// timer would not get to it. Bounded, because quitting must not hang on
|
||||
/// a slow network; a sync cut short loses nothing, the next launch sends
|
||||
/// the same table.
|
||||
pub fn sync_before_exit(&self, timeout: std::time::Duration) {
|
||||
let (Some(account), Some(sync)) = (self.account.lock().clone(), self.watch_sync.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let catalog = Arc::clone(&self.catalog);
|
||||
self.runtime.block_on(async move {
|
||||
match tokio::time::timeout(timeout, sync.sync(&account, &catalog)).await {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(error)) => tracing::warn!("sync watch history on exit: {error}"),
|
||||
Err(_) => tracing::warn!("sync watch history on exit: timed out"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn sync_periodically(&self) {
|
||||
let engine = self.clone();
|
||||
self.runtime.spawn(async move {
|
||||
|
||||
@@ -30,5 +30,6 @@ pub mod pacing;
|
||||
pub mod playback;
|
||||
pub mod theme;
|
||||
pub mod ui;
|
||||
pub mod verify;
|
||||
pub mod video;
|
||||
pub mod window;
|
||||
@@ -16,6 +16,12 @@ use anyhow::Context;
|
||||
use pstr_core::config::AppDirs;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
// Before anything else: a verification window is a child process of the
|
||||
// app, and must not claim the single instance or open a library.
|
||||
if let Some(code) = pstr_app::verify::run_if_requested() {
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
|
||||
@@ -207,6 +207,12 @@ fn code_form(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Acti
|
||||
.desired_width(160.0),
|
||||
);
|
||||
field.request_focus();
|
||||
ui.add_space(theme::space::S);
|
||||
// Security keys need WebAuthn, which the sign-in here does not speak.
|
||||
ui.label(ui::muted(
|
||||
"Security keys are not supported. If your account has only a security key, add an \
|
||||
authenticator app in your Proton account settings to sign in here.",
|
||||
));
|
||||
ui.add_space(theme::space::L);
|
||||
error_line(ui, panel);
|
||||
let ready = !panel.code.trim().is_empty() && !panel.busy;
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Proton's CAPTCHA, in a window of its own.
|
||||
//!
|
||||
//! Proton puts a CAPTCHA in front of a sign-in it does not recognise — a new
|
||||
//! network, a VPN exit. The page reports success by posting a message to its
|
||||
//! host rather than by redirecting, so the system browser has nowhere to send
|
||||
//! it back to: it needs a webview this app controls, as on Android and in
|
||||
//! `proton-drive-linux`.
|
||||
//!
|
||||
//! The webview runs in a child process — this same executable, started with
|
||||
//! [`ARG`] — because its windowing library and eframe's cannot both own the
|
||||
//! event loop of one process. The child prints the solved token on stdout and
|
||||
//! exits; closing its window exits with nothing printed, which is a cancel.
|
||||
|
||||
use std::io::{Read as _, Write as _};
|
||||
|
||||
/// The argument that starts this executable as the verification window,
|
||||
/// followed by the page's URL.
|
||||
pub const ARG: &str = "--human-verification";
|
||||
|
||||
/// Forwards every message the page posts to this process. The page targets
|
||||
/// `window.parent`, which for a top-level page is the page itself, so a
|
||||
/// listener on its own window hears it.
|
||||
const BRIDGE: &str = r#"
|
||||
window.addEventListener('message', function (event) {
|
||||
try {
|
||||
window.ipc.postMessage(typeof event.data === 'string' ? event.data : JSON.stringify(event.data));
|
||||
} catch (e) {}
|
||||
});
|
||||
"#;
|
||||
|
||||
/// Run the verification window if this process was started for it, returning
|
||||
/// the process's exit code; `None` when it was started as the app.
|
||||
pub fn run_if_requested() -> Option<i32> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
if args.next().as_deref() != Some(ARG) {
|
||||
return None;
|
||||
}
|
||||
let Some(url) = args.next().filter(|url| is_proton_page(url)) else {
|
||||
eprintln!("{ARG} needs a verification page on proton.me");
|
||||
return Some(2);
|
||||
};
|
||||
Some(match window(&url) {
|
||||
Ok(()) => 0,
|
||||
Err(error) => {
|
||||
eprintln!("the verification window failed: {error}");
|
||||
1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Present `url` in the verification window and wait for the token it yields.
|
||||
///
|
||||
/// `None` when the viewer closed the window, or it could not be shown.
|
||||
/// Blocking: call it off the UI thread and outside the async runtime.
|
||||
pub fn solve(url: &str) -> Option<String> {
|
||||
let exe = std::env::current_exe()
|
||||
.inspect_err(|error| tracing::warn!("locate this executable: {error}"))
|
||||
.ok()?;
|
||||
let mut child = std::process::Command::new(exe)
|
||||
.arg(ARG)
|
||||
.arg(url)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.inspect_err(|error| tracing::warn!("open the verification window: {error}"))
|
||||
.ok()?;
|
||||
let mut out = String::new();
|
||||
if let Some(mut stdout) = child.stdout.take() {
|
||||
let _ = stdout.read_to_string(&mut out);
|
||||
}
|
||||
let status = child.wait().ok()?;
|
||||
if !status.success() {
|
||||
tracing::warn!("the verification window exited with {status}");
|
||||
return None;
|
||||
}
|
||||
out.lines()
|
||||
.map(str::trim)
|
||||
.rfind(|line| !line.is_empty())
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
fn window(url: &str) -> anyhow::Result<()> {
|
||||
use tao::event::{Event, WindowEvent};
|
||||
use tao::event_loop::{ControlFlow, EventLoopBuilder};
|
||||
use tao::window::WindowBuilder;
|
||||
use wry::{NewWindowResponse, WebViewBuilder};
|
||||
|
||||
let event_loop = EventLoopBuilder::<String>::with_user_event().build();
|
||||
let window = WindowBuilder::new()
|
||||
.with_title("Confirm you are human — Proton Stream")
|
||||
.with_inner_size(tao::dpi::LogicalSize::new(440.0, 680.0))
|
||||
.build(&event_loop)?;
|
||||
|
||||
let proxy = event_loop.create_proxy();
|
||||
let builder = WebViewBuilder::new()
|
||||
.with_url(url)
|
||||
.with_initialization_script(BRIDGE)
|
||||
.with_ipc_handler(move |request| {
|
||||
// Every message the page posts lands here, most of them its own
|
||||
// chatter; only a completion carries a token.
|
||||
if let Some(token) = pstr_core::account::verification_token(request.body()) {
|
||||
let _ = proxy.send_event(token);
|
||||
}
|
||||
})
|
||||
// The bridge above is reachable by whatever the page shows, so the
|
||||
// page stays on Proton.
|
||||
.with_navigation_handler(|url| is_allowed_navigation(&url))
|
||||
.with_new_window_req_handler(|_, _| NewWindowResponse::Deny);
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
let _webview = {
|
||||
use tao::platform::unix::WindowExtUnix as _;
|
||||
use wry::WebViewBuilderExtUnix as _;
|
||||
let vbox = window
|
||||
.default_vbox()
|
||||
.ok_or_else(|| anyhow::anyhow!("the window has no GTK container"))?;
|
||||
builder.build_gtk(vbox)?
|
||||
};
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
let _webview = builder.build(&window)?;
|
||||
|
||||
let mut answered = false;
|
||||
event_loop.run(move |event, _, control_flow| {
|
||||
*control_flow = ControlFlow::Wait;
|
||||
match event {
|
||||
Event::UserEvent(token) if !answered => {
|
||||
answered = true;
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
let _ = writeln!(stdout, "{token}");
|
||||
let _ = stdout.flush();
|
||||
*control_flow = ControlFlow::Exit;
|
||||
}
|
||||
Event::WindowEvent {
|
||||
event: WindowEvent::CloseRequested,
|
||||
..
|
||||
} => *control_flow = ControlFlow::Exit,
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `url` is a page of Proton's own, over HTTPS.
|
||||
fn is_proton_page(url: &str) -> bool {
|
||||
let Some(rest) = url.strip_prefix("https://") else {
|
||||
return false;
|
||||
};
|
||||
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
|
||||
// A user-info part would let `proton.me@evil.example` pass.
|
||||
if authority.contains('@') {
|
||||
return false;
|
||||
}
|
||||
let host = authority.split(':').next().unwrap_or_default();
|
||||
host == "proton.me" || host.ends_with(".proton.me")
|
||||
}
|
||||
|
||||
/// Whether the webview may go to `url`: Proton's pages, and the documents a
|
||||
/// page makes for itself, which have no origin of their own to leave to.
|
||||
fn is_allowed_navigation(url: &str) -> bool {
|
||||
is_proton_page(url) || url == "about:blank" || url.starts_with("about:srcdoc")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_proton_pages_over_https_are_proton_pages() {
|
||||
assert!(is_proton_page(
|
||||
"https://verify.proton.me/?embed=1&methods=captcha&token=x"
|
||||
));
|
||||
assert!(is_proton_page("https://proton.me"));
|
||||
assert!(!is_proton_page("http://verify.proton.me/"));
|
||||
assert!(!is_proton_page("https://proton.me.evil.example/"));
|
||||
assert!(!is_proton_page("https://evilproton.me/"));
|
||||
assert!(!is_proton_page("https://proton.me@evil.example/"));
|
||||
assert!(!is_proton_page(
|
||||
"https://evil.example/?next=https://proton.me"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_window_stays_on_proton() {
|
||||
assert!(is_allowed_navigation(
|
||||
"https://verify-api.proton.me/core/v4/captcha"
|
||||
));
|
||||
assert!(is_allowed_navigation("about:blank"));
|
||||
assert!(!is_allowed_navigation("https://example.com/"));
|
||||
assert!(!is_allowed_navigation("file:///etc/passwd"));
|
||||
}
|
||||
}
|
||||
@@ -1084,7 +1084,6 @@ async fn play(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unix seconds, for the "when was this asked" columns.
|
||||
/// Walk an interactive sign-in through whatever the account asks for.
|
||||
async fn login(accounts: &AccountStore, username: &str) -> Result<()> {
|
||||
let password = read_password("Password: ")?;
|
||||
@@ -1092,10 +1091,12 @@ async fn login(accounts: &AccountStore, username: &str) -> Result<()> {
|
||||
Ok(step) => step,
|
||||
Err(pstr_core::Error::HumanVerification(challenge)) => {
|
||||
// Solving a CAPTCHA needs a browser engine that can hand the
|
||||
// token back, which a terminal does not have.
|
||||
// token back, which a terminal does not have. The desktop app has
|
||||
// one, and keeps its session in the same keyring entry.
|
||||
anyhow::bail!(
|
||||
"Proton wants a CAPTCHA before it accepts this sign-in. Sign in once in the \
|
||||
desktop or Android app, or try again later. ({})",
|
||||
desktop app on this computer, which shows it and shares its session with \
|
||||
pstr, or try again later. ({})",
|
||||
challenge.verification_url()
|
||||
);
|
||||
}
|
||||
@@ -1108,7 +1109,8 @@ async fn login(accounts: &AccountStore, username: &str) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
SignIn::SecondFactor(pending) => {
|
||||
let code = read_password("Two-factor code: ")?;
|
||||
// Security keys need WebAuthn, which this sign-in does not speak.
|
||||
let code = read_password("Code from your authenticator app: ")?;
|
||||
pending
|
||||
.second_factor(&code)
|
||||
.await
|
||||
|
||||
@@ -513,19 +513,17 @@ impl Catalog {
|
||||
Ok(rows.collect::<std::result::Result<_, _>>()?)
|
||||
}
|
||||
|
||||
/// Drop everything a share contributed, watch positions included.
|
||||
/// Drop everything a share contributed except where it was watched.
|
||||
///
|
||||
/// Unlike a recrawl — which replaces rows and deliberately leaves watch
|
||||
/// state alone — this is the viewer saying they are done with the share.
|
||||
/// Keeping positions for files they can no longer reach would only leave
|
||||
/// the "continue watching" row pointing at nothing.
|
||||
/// Watch state stays, as through a recrawl. It is history, and not only
|
||||
/// this device's: with an account signed in, the next sync would bring it
|
||||
/// straight back from the other devices' files, so deleting it here would
|
||||
/// only make it flicker. Nothing shows it — the library pairs positions
|
||||
/// with files, and the files are gone — until the share is added again,
|
||||
/// when it picks up where it left off.
|
||||
pub fn remove_share(&mut self, share_id: &str) -> Result<()> {
|
||||
let tx = self.conn.transaction()?;
|
||||
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
|
||||
tx.execute(
|
||||
"DELETE FROM watch_state WHERE share_id = ?1",
|
||||
params![share_id],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM offline_files WHERE share_id = ?1",
|
||||
params![share_id],
|
||||
@@ -2430,6 +2428,38 @@ mod tests {
|
||||
assert_eq!(rows[0].link_id, "film");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_share_keeps_its_watch_state_out_of_sight() {
|
||||
let mut catalog = Catalog::in_memory().expect("open");
|
||||
catalog
|
||||
.replace_share("s1", &[file("s1", "a", "Show S01E01.mkv")])
|
||||
.expect("crawl");
|
||||
let state = WatchState {
|
||||
position_secs: 120.0,
|
||||
duration_secs: Some(1440.0),
|
||||
watched: false,
|
||||
updated_at: 10,
|
||||
};
|
||||
catalog.set_watch_state("s1", "a", &state).expect("set");
|
||||
|
||||
catalog.remove_share("s1").expect("remove");
|
||||
let library = crate::library::Library::build(
|
||||
catalog.all_files().expect("files"),
|
||||
&catalog.all_watch_states().expect("watch"),
|
||||
);
|
||||
assert!(library.titles.is_empty());
|
||||
assert_eq!(catalog.watch_state("s1", "a").expect("read"), Some(state));
|
||||
|
||||
catalog
|
||||
.replace_share("s1", &[file("s1", "a", "Show S01E01.mkv")])
|
||||
.expect("re-add");
|
||||
let library = crate::library::Library::build(
|
||||
catalog.all_files().expect("files"),
|
||||
&catalog.all_watch_states().expect("watch"),
|
||||
);
|
||||
assert_eq!(library.titles[0].seasons[0].episodes[0].watch, Some(state));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merging_takes_only_states_newer_than_the_recorded_ones() {
|
||||
let mut catalog = Catalog::in_memory().expect("open");
|
||||
|
||||
@@ -55,19 +55,26 @@ pub trait SecretStore: Send + Sync {
|
||||
}
|
||||
|
||||
/// The desktop OS credential store.
|
||||
///
|
||||
/// A value too long for one entry is split across several; see
|
||||
/// [`ENTRY_UNITS`].
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[derive(Debug, Default)]
|
||||
pub struct KeyringSecretStore;
|
||||
|
||||
/// One credential-store entry per key, with no splitting.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
impl KeyringSecretStore {
|
||||
struct KeyringEntries;
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
impl KeyringEntries {
|
||||
fn entry(key: &str) -> Result<keyring::Entry> {
|
||||
Ok(keyring::Entry::new(KEYRING_SERVICE, key)?)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
impl SecretStore for KeyringSecretStore {
|
||||
impl SecretStore for KeyringEntries {
|
||||
fn set(&self, key: &str, value: &str) -> Result<()> {
|
||||
Self::entry(key)?.set_password(value)?;
|
||||
Ok(())
|
||||
@@ -89,6 +96,205 @@ impl SecretStore for KeyringSecretStore {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
impl SecretStore for KeyringSecretStore {
|
||||
fn set(&self, key: &str, value: &str) -> Result<()> {
|
||||
chunked::set(&KeyringEntries, key, value)
|
||||
}
|
||||
|
||||
fn get(&self, key: &str) -> Result<Option<String>> {
|
||||
chunked::get(&KeyringEntries, key)
|
||||
}
|
||||
|
||||
fn delete(&self, key: &str) -> Result<()> {
|
||||
chunked::delete(&KeyringEntries, key)
|
||||
}
|
||||
}
|
||||
|
||||
/// The most UTF-16 code units one credential-store entry is given.
|
||||
///
|
||||
/// Windows caps a credential's blob at 2560 bytes, and `keyring` stores it as
|
||||
/// UTF-16: 1280 units. A share secret is far below that, but the account
|
||||
/// session — tokens, scopes, the mailbox password and a salt per key — can
|
||||
/// pass it, and failing to store a refreshed token signs the viewer out at
|
||||
/// the next launch. The margin is for the header.
|
||||
#[cfg(any(not(target_os = "android"), test))]
|
||||
const ENTRY_UNITS: usize = 1200;
|
||||
|
||||
/// Splitting a long secret across several entries of an underlying store.
|
||||
///
|
||||
/// A short value is one entry, exactly as before splitting existed, so every
|
||||
/// secret already stored still reads. A long one is written as `key#0`,
|
||||
/// `key#1`, … with `key` itself holding [`HEADER`] and the count. The parts go
|
||||
/// first and the header last, so a reader never finds a header naming parts
|
||||
/// that are not there yet.
|
||||
#[cfg(any(not(target_os = "android"), test))]
|
||||
mod chunked {
|
||||
use super::{ENTRY_UNITS, SecretStore};
|
||||
use crate::error::Result;
|
||||
|
||||
/// Marks a split value. A short value that happens to start with it is
|
||||
/// split too, so it cannot be mistaken for a header.
|
||||
const HEADER: &str = "pstr-chunked:";
|
||||
|
||||
fn part(key: &str, index: usize) -> String {
|
||||
format!("{key}#{index}")
|
||||
}
|
||||
|
||||
fn parts_of(header: &str) -> Option<usize> {
|
||||
header.strip_prefix(HEADER)?.parse().ok()
|
||||
}
|
||||
|
||||
/// `value` cut into pieces of at most [`ENTRY_UNITS`] UTF-16 units, never
|
||||
/// inside a character.
|
||||
fn split(value: &str) -> Vec<&str> {
|
||||
let mut pieces = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut units = 0;
|
||||
for (at, ch) in value.char_indices() {
|
||||
if units + ch.len_utf16() > ENTRY_UNITS {
|
||||
pieces.push(&value[start..at]);
|
||||
start = at;
|
||||
units = 0;
|
||||
}
|
||||
units += ch.len_utf16();
|
||||
}
|
||||
pieces.push(&value[start..]);
|
||||
pieces
|
||||
}
|
||||
|
||||
pub(super) fn set(store: &dyn SecretStore, key: &str, value: &str) -> Result<()> {
|
||||
let before = store.get(key)?.as_deref().and_then(parts_of).unwrap_or(0);
|
||||
let fits = value.encode_utf16().count() <= ENTRY_UNITS && !value.starts_with(HEADER);
|
||||
let written = if fits {
|
||||
store.set(key, value)?;
|
||||
0
|
||||
} else {
|
||||
let pieces = split(value);
|
||||
for (index, piece) in pieces.iter().enumerate() {
|
||||
store.set(&part(key, index), piece)?;
|
||||
}
|
||||
store.set(key, &format!("{HEADER}{}", pieces.len()))?;
|
||||
pieces.len()
|
||||
};
|
||||
for index in written..before {
|
||||
store.delete(&part(key, index))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn get(store: &dyn SecretStore, key: &str) -> Result<Option<String>> {
|
||||
let Some(value) = store.get(key)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(count) = parts_of(&value) else {
|
||||
return Ok(Some(value));
|
||||
};
|
||||
let mut joined = String::new();
|
||||
for index in 0..count {
|
||||
// A part gone missing is a value that no longer exists whole;
|
||||
// half of a token is worse than none.
|
||||
let Some(piece) = store.get(&part(key, index))? else {
|
||||
return Ok(None);
|
||||
};
|
||||
joined.push_str(&piece);
|
||||
}
|
||||
Ok(Some(joined))
|
||||
}
|
||||
|
||||
pub(super) fn delete(store: &dyn SecretStore, key: &str) -> Result<()> {
|
||||
let count = store.get(key)?.as_deref().and_then(parts_of).unwrap_or(0);
|
||||
store.delete(key)?;
|
||||
for index in 0..count {
|
||||
store.delete(&part(key, index))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// An underlying store that refuses what Windows would.
|
||||
#[derive(Default)]
|
||||
struct Limited(Mutex<BTreeMap<String, String>>);
|
||||
|
||||
impl SecretStore for Limited {
|
||||
fn set(&self, key: &str, value: &str) -> Result<()> {
|
||||
assert!(
|
||||
value.encode_utf16().count() <= 1280,
|
||||
"{key} is too long for one entry"
|
||||
);
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_secret_is_one_entry_as_it_always_was() {
|
||||
let store = Limited::default();
|
||||
set(&store, "share-a", "fragment").unwrap();
|
||||
assert_eq!(store.0.lock().len(), 1);
|
||||
assert_eq!(get(&store, "share-a").unwrap().as_deref(), Some("fragment"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secret_longer_than_an_entry_round_trips() {
|
||||
let store = Limited::default();
|
||||
let long: String = "aé😀".repeat(1500);
|
||||
set(&store, "proton-account", &long).unwrap();
|
||||
assert!(store.0.lock().len() > 2);
|
||||
assert_eq!(get(&store, "proton-account").unwrap(), Some(long));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shorter_value_leaves_no_parts_of_the_longer_one_behind() {
|
||||
let store = Limited::default();
|
||||
set(&store, "k", &"x".repeat(5000)).unwrap();
|
||||
set(&store, "k", &"y".repeat(1300)).unwrap();
|
||||
assert_eq!(store.0.lock().len(), 3);
|
||||
set(&store, "k", "short").unwrap();
|
||||
assert_eq!(store.0.lock().len(), 1);
|
||||
assert_eq!(get(&store, "k").unwrap().as_deref(), Some("short"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_that_looks_like_a_header_is_not_taken_for_one() {
|
||||
let store = Limited::default();
|
||||
set(&store, "k", "pstr-chunked:3").unwrap();
|
||||
assert_eq!(get(&store, "k").unwrap().as_deref(), Some("pstr-chunked:3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_split_secret_deletes_every_part() {
|
||||
let store = Limited::default();
|
||||
set(&store, "k", &"x".repeat(5000)).unwrap();
|
||||
delete(&store, "k").unwrap();
|
||||
assert!(store.0.lock().is_empty());
|
||||
assert_eq!(get(&store, "k").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_split_secret_missing_a_part_reads_as_absent() {
|
||||
let store = Limited::default();
|
||||
set(&store, "k", &"x".repeat(5000)).unwrap();
|
||||
store.delete("k#1").unwrap();
|
||||
assert_eq!(get(&store, "k").unwrap(), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the app records about a share, minus its secrets.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Share {
|
||||
|
||||
+601
-92
@@ -32,7 +32,7 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use proton_drive_rs::{DeviceType, NodeKind};
|
||||
use proton_drive_rs::{DeviceType, NodeKind, ProtonDriveClient};
|
||||
use proton_sdk::ids::NodeUid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -120,12 +120,133 @@ pub struct SyncReport {
|
||||
pub uploaded: bool,
|
||||
/// Other installations whose history was read.
|
||||
pub devices: usize,
|
||||
/// Files of installations gone quiet that were moved to the trash, their
|
||||
/// history now carried in this installation's own file.
|
||||
pub retired: usize,
|
||||
}
|
||||
|
||||
/// A history file as last read, by the revision it was read at.
|
||||
/// How long an installation's file may go unwritten before another
|
||||
/// installation retires it.
|
||||
///
|
||||
/// Reinstalling — routine on Android, where uninstalling wipes the config —
|
||||
/// starts a new id, and every file ever written would otherwise be read by
|
||||
/// every device forever. Retiring is safe at any age: a file is only trashed
|
||||
/// after its entries were merged and this installation's file, which then
|
||||
/// holds them too, is written. An installation that was merely idle finds its
|
||||
/// file gone on its next sync and writes it again.
|
||||
const RETIRE_AFTER_SECS: i64 = 90 * 24 * 60 * 60;
|
||||
|
||||
/// One history file in Drive, as listed.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct RemoteFile {
|
||||
pub uid: NodeUid,
|
||||
pub name: String,
|
||||
/// The active revision, which moves with every write.
|
||||
pub revision: Option<String>,
|
||||
/// Last modification, epoch seconds.
|
||||
pub modified: i64,
|
||||
}
|
||||
|
||||
/// The few Drive operations sync needs: the account's Drive in the app, an
|
||||
/// in-memory one in the tests.
|
||||
pub(crate) trait HistoryDrive: Sync {
|
||||
/// The history folders, creating the device and folder the first time.
|
||||
/// See [`WatchSync::folders`].
|
||||
fn history_folders(&self) -> impl Future<Output = Result<Vec<NodeUid>>> + Send;
|
||||
/// The untrashed `.json` files directly in `folder`.
|
||||
fn list(&self, folder: &NodeUid) -> impl Future<Output = Result<Vec<RemoteFile>>> + Send;
|
||||
fn download(&self, uid: &NodeUid) -> impl Future<Output = Result<Vec<u8>>> + Send;
|
||||
fn create(
|
||||
&self,
|
||||
folder: &NodeUid,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
) -> impl Future<Output = Result<()>> + Send;
|
||||
fn replace(&self, uid: &NodeUid, bytes: &[u8]) -> impl Future<Output = Result<()>> + Send;
|
||||
fn trash(&self, uids: &[NodeUid]) -> impl Future<Output = Result<()>> + Send;
|
||||
}
|
||||
|
||||
impl HistoryDrive for ProtonDriveClient {
|
||||
/// 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 history_folders(&self) -> Result<Vec<NodeUid>> {
|
||||
let mut devices: Vec<_> = self
|
||||
.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(self.create_device(DEVICE_NAME, device_type()).await?);
|
||||
}
|
||||
let mut folders = Vec::with_capacity(devices.len());
|
||||
for device in &devices {
|
||||
folders.push(
|
||||
self.create_folder_path(&device.root_folder_uid, FOLDER)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
Ok(folders)
|
||||
}
|
||||
|
||||
async fn list(&self, folder: &NodeUid) -> Result<Vec<RemoteFile>> {
|
||||
let uids = self.enumerate_folder_children_node_uids(folder).await?;
|
||||
if uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(self
|
||||
.enumerate_nodes(&uids)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|node| {
|
||||
let NodeKind::File {
|
||||
active_revision_id, ..
|
||||
} = node.kind
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
(!node.trashed && node.name.ends_with(".json")).then_some(RemoteFile {
|
||||
uid: node.uid,
|
||||
name: node.name,
|
||||
revision: active_revision_id,
|
||||
modified: node.modification_time,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn download(&self, uid: &NodeUid) -> Result<Vec<u8>> {
|
||||
Ok(self.download_file(uid).await?)
|
||||
}
|
||||
|
||||
async fn create(&self, folder: &NodeUid, name: &str, bytes: &[u8]) -> Result<()> {
|
||||
self.upload_file(folder, name, "application/json", bytes)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn replace(&self, uid: &NodeUid, bytes: &[u8]) -> Result<()> {
|
||||
Ok(self.upload_new_revision(uid, bytes).await?)
|
||||
}
|
||||
|
||||
async fn trash(&self, uids: &[NodeUid]) -> Result<()> {
|
||||
for (uid, outcome) in self.trash_nodes(uids).await? {
|
||||
if let Err(error) = outcome {
|
||||
tracing::warn!(%uid, %error, "a retired watch-history file was not trashed");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A history file as last read, by the revision it was read at. `None`
|
||||
/// entries for one this build could not read.
|
||||
struct Seen {
|
||||
revision: Option<String>,
|
||||
entries: Vec<HistoryEntry>,
|
||||
entries: Option<Vec<HistoryEntry>>,
|
||||
}
|
||||
|
||||
/// Syncs the catalog's watch table with the account's Drive.
|
||||
@@ -139,7 +260,7 @@ pub struct WatchSync {
|
||||
/// 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`].
|
||||
/// [`HistoryDrive::history_folders`].
|
||||
folders: Mutex<Option<Vec<NodeUid>>>,
|
||||
/// One sync at a time: two would race on creating the device and the
|
||||
/// installation's file.
|
||||
@@ -182,60 +303,70 @@ impl WatchSync {
|
||||
*self.folders.lock() = None;
|
||||
}
|
||||
|
||||
/// Pull every other installation's history into `catalog`, then write
|
||||
/// this one's if it changed.
|
||||
/// Pull every other installation's history into `catalog`, 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> {
|
||||
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
|
||||
}
|
||||
|
||||
pub(crate) async fn sync_with<D: HistoryDrive>(
|
||||
&self,
|
||||
drive: &D,
|
||||
catalog: &Mutex<Catalog>,
|
||||
now: i64,
|
||||
) -> Result<SyncReport> {
|
||||
let _running = self.running.lock().await;
|
||||
let drive = account.drive();
|
||||
let folders = self.folders(account).await?;
|
||||
let folders = self.folders(drive).await?;
|
||||
let own_name = format!("{}.json", self.installation);
|
||||
|
||||
let mut own_file = None;
|
||||
let mut remote = Vec::new();
|
||||
let mut devices = 0;
|
||||
let mut others = Vec::new();
|
||||
for folder in &folders {
|
||||
let uids = drive.enumerate_folder_children_node_uids(folder).await?;
|
||||
if uids.is_empty() {
|
||||
continue;
|
||||
for file in drive.list(folder).await? {
|
||||
if file.name == own_name {
|
||||
own_file.get_or_insert(file);
|
||||
} else {
|
||||
others.push(file);
|
||||
}
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut remote = 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);
|
||||
readable.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
// What this installation wrote before is history too: after a
|
||||
// reinstall that kept the config, or a catalog that was deleted. One
|
||||
// that cannot be read now is left alone rather than overwritten with
|
||||
// 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 {
|
||||
Some((uid, revision)) => self.read(account, uid, revision.clone()).await,
|
||||
None => Vec::new(),
|
||||
Some(file) => self.read(drive, file).await,
|
||||
None => Some(Vec::new()),
|
||||
};
|
||||
// What this installation wrote before is history too: after a
|
||||
// reinstall that kept the config, or a catalog that was deleted.
|
||||
match entries {
|
||||
Some(entries) => {
|
||||
remote.extend(entries.iter().cloned());
|
||||
*self.own.lock() = Some(entries);
|
||||
}
|
||||
None => own_unreadable = true,
|
||||
}
|
||||
}
|
||||
|
||||
let incoming: Vec<(String, String, WatchState)> = remote
|
||||
.iter()
|
||||
@@ -254,8 +385,12 @@ impl WatchSync {
|
||||
.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()) {
|
||||
// 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())
|
||||
{
|
||||
false
|
||||
} else {
|
||||
let file = HistoryFile {
|
||||
@@ -266,94 +401,91 @@ impl WatchSync {
|
||||
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?;
|
||||
}
|
||||
Some(file) => drive.replace(&file.uid, &bytes).await?,
|
||||
None => drive.create(&folders[0], &own_name, &bytes).await?,
|
||||
}
|
||||
*self.own.lock() = Some(entries);
|
||||
true
|
||||
};
|
||||
|
||||
// Only now: every entry of a retired file was merged above, and this
|
||||
// installation's file — just written, or already up to date — holds
|
||||
// the merged table.
|
||||
let retired: Vec<NodeUid> = readable
|
||||
.into_iter()
|
||||
.filter(|file| !own_unreadable && now - file.modified >= RETIRE_AFTER_SECS)
|
||||
.map(|file| file.uid.clone())
|
||||
.collect();
|
||||
if !retired.is_empty() {
|
||||
match drive.trash(&retired).await {
|
||||
Ok(()) => {
|
||||
let mut seen = self.seen.lock();
|
||||
for uid in &retired {
|
||||
seen.remove(uid.link_id.as_str());
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "retiring old watch-history files failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SyncReport {
|
||||
applied,
|
||||
uploaded,
|
||||
devices,
|
||||
devices: others.len(),
|
||||
retired: retired.len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The entries of one history file, from the cache when its revision has
|
||||
/// not moved.
|
||||
/// not moved, or `None` when it cannot be read.
|
||||
///
|
||||
/// 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(
|
||||
/// 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,
|
||||
account: &Account,
|
||||
uid: &NodeUid,
|
||||
revision: Option<String>,
|
||||
) -> Vec<HistoryEntry> {
|
||||
let key = uid.link_id.as_str().to_owned();
|
||||
drive: &D,
|
||||
file: &RemoteFile,
|
||||
) -> Option<Vec<HistoryEntry>> {
|
||||
let key = file.uid.link_id.as_str().to_owned();
|
||||
if let Some(seen) = self.seen.lock().get(&key)
|
||||
&& revision.is_some()
|
||||
&& seen.revision == revision
|
||||
&& file.revision.is_some()
|
||||
&& seen.revision == file.revision
|
||||
{
|
||||
return seen.entries.clone();
|
||||
}
|
||||
let entries = match account.drive().download_file(uid).await {
|
||||
Ok(bytes) => match parse(&bytes) {
|
||||
Some(entries) => entries,
|
||||
None => {
|
||||
let uid = &file.uid;
|
||||
let entries = match drive.download(uid).await {
|
||||
Ok(bytes) => {
|
||||
let entries = parse(&bytes);
|
||||
if entries.is_none() {
|
||||
tracing::warn!(%uid, "skipping a watch-history file this build cannot read");
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
entries
|
||||
}
|
||||
Err(error) => {
|
||||
// Not cached: a download that failed is tried again next time.
|
||||
tracing::warn!(%uid, %error, "a watch-history file could not be downloaded");
|
||||
return Vec::new();
|
||||
return None;
|
||||
}
|
||||
};
|
||||
self.seen.lock().insert(
|
||||
key,
|
||||
Seen {
|
||||
revision,
|
||||
revision: file.revision.clone(),
|
||||
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>> {
|
||||
/// The history folders, resolved once per account.
|
||||
async fn folders<D: HistoryDrive>(&self, drive: &D) -> 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?,
|
||||
);
|
||||
}
|
||||
let folders = drive.history_folders().await?;
|
||||
*self.folders.lock() = Some(folders.clone());
|
||||
Ok(folders)
|
||||
}
|
||||
@@ -398,6 +530,383 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// One history folder in memory, standing in for the account's Drive.
|
||||
#[derive(Default)]
|
||||
struct FakeDrive {
|
||||
files: Mutex<Vec<FakeFile>>,
|
||||
/// What the next write is stamped with, epoch seconds.
|
||||
clock: std::sync::atomic::AtomicI64,
|
||||
/// Downloads fail while set, as on a dropped connection.
|
||||
offline: std::sync::atomic::AtomicBool,
|
||||
uploads: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
struct FakeFile {
|
||||
uid: NodeUid,
|
||||
name: String,
|
||||
bytes: Vec<u8>,
|
||||
revision: u32,
|
||||
modified: i64,
|
||||
trashed: bool,
|
||||
}
|
||||
|
||||
fn uid(link: &str) -> NodeUid {
|
||||
NodeUid::new("volume".to_owned().into(), link.to_owned().into())
|
||||
}
|
||||
|
||||
impl FakeDrive {
|
||||
fn at(&self, now: i64) {
|
||||
self.clock.store(now, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn names(&self) -> Vec<String> {
|
||||
let mut names: Vec<_> = self
|
||||
.files
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|file| !file.trashed)
|
||||
.map(|file| file.name.clone())
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
fn uploads(&self) -> usize {
|
||||
self.uploads.load(std::sync::atomic::Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn put(&self, name: &str, bytes: Vec<u8>) {
|
||||
let modified = self.clock.load(std::sync::atomic::Ordering::SeqCst);
|
||||
let mut files = self.files.lock();
|
||||
let link = format!("file-{}", files.len());
|
||||
files.push(FakeFile {
|
||||
uid: uid(&link),
|
||||
name: name.to_owned(),
|
||||
bytes,
|
||||
revision: 1,
|
||||
modified,
|
||||
trashed: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryDrive for FakeDrive {
|
||||
async fn history_folders(&self) -> Result<Vec<NodeUid>> {
|
||||
Ok(vec![uid("folder")])
|
||||
}
|
||||
|
||||
async fn list(&self, _folder: &NodeUid) -> Result<Vec<RemoteFile>> {
|
||||
Ok(self
|
||||
.files
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|file| !file.trashed)
|
||||
.map(|file| RemoteFile {
|
||||
uid: file.uid.clone(),
|
||||
name: file.name.clone(),
|
||||
revision: Some(file.revision.to_string()),
|
||||
modified: file.modified,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn download(&self, uid: &NodeUid) -> Result<Vec<u8>> {
|
||||
if self.offline.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
return Err(Error::NotFound("offline".to_owned()));
|
||||
}
|
||||
self.files
|
||||
.lock()
|
||||
.iter()
|
||||
.find(|file| &file.uid == uid)
|
||||
.map(|file| file.bytes.clone())
|
||||
.ok_or_else(|| Error::NotFound(uid.to_string()))
|
||||
}
|
||||
|
||||
async fn create(&self, _folder: &NodeUid, name: &str, bytes: &[u8]) -> Result<()> {
|
||||
self.uploads
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
self.put(name, bytes.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn replace(&self, uid: &NodeUid, bytes: &[u8]) -> Result<()> {
|
||||
self.uploads
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
let modified = self.clock.load(std::sync::atomic::Ordering::SeqCst);
|
||||
let mut files = self.files.lock();
|
||||
let file = files
|
||||
.iter_mut()
|
||||
.find(|file| &file.uid == uid)
|
||||
.ok_or_else(|| Error::NotFound(uid.to_string()))?;
|
||||
file.bytes = bytes.to_vec();
|
||||
file.revision += 1;
|
||||
file.modified = modified;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn trash(&self, uids: &[NodeUid]) -> Result<()> {
|
||||
for file in self.files.lock().iter_mut() {
|
||||
if uids.contains(&file.uid) {
|
||||
file.trashed = true;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 watched_at(position_secs: f64, updated_at: i64) -> WatchState {
|
||||
WatchState {
|
||||
position_secs,
|
||||
duration_secs: Some(1440.0),
|
||||
watched: false,
|
||||
updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
const DAY: i64 = 24 * 60 * 60;
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_position_saved_on_one_device_resumes_on_another() {
|
||||
let drive = FakeDrive::default();
|
||||
let (laptop, laptop_catalog) = device("laptop");
|
||||
let (phone, phone_catalog) = device("phone");
|
||||
laptop_catalog
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep1", &watched_at(600.0, 100))
|
||||
.unwrap();
|
||||
|
||||
let first = laptop
|
||||
.sync_with(&drive, &laptop_catalog, 100)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(first.uploaded);
|
||||
let second = phone.sync_with(&drive, &phone_catalog, 110).await.unwrap();
|
||||
|
||||
assert_eq!(second.applied, 1);
|
||||
assert_eq!(second.devices, 1);
|
||||
assert_eq!(
|
||||
phone_catalog.lock().watch_state("share-a", "ep1").unwrap(),
|
||||
Some(watched_at(600.0, 100))
|
||||
);
|
||||
assert_eq!(drive.names(), ["laptop.json", "phone.json"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_newer_position_wins_whichever_device_syncs_first() {
|
||||
let drive = FakeDrive::default();
|
||||
let (laptop, laptop_catalog) = device("laptop");
|
||||
let (phone, phone_catalog) = device("phone");
|
||||
laptop_catalog
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep1", &watched_at(300.0, 100))
|
||||
.unwrap();
|
||||
phone_catalog
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep1", &watched_at(900.0, 200))
|
||||
.unwrap();
|
||||
|
||||
laptop
|
||||
.sync_with(&drive, &laptop_catalog, 300)
|
||||
.await
|
||||
.unwrap();
|
||||
phone.sync_with(&drive, &phone_catalog, 300).await.unwrap();
|
||||
laptop
|
||||
.sync_with(&drive, &laptop_catalog, 300)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for catalog in [&laptop_catalog, &phone_catalog] {
|
||||
assert_eq!(
|
||||
catalog.lock().watch_state("share-a", "ep1").unwrap(),
|
||||
Some(watched_at(900.0, 200))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unchanged_history_is_not_uploaded_again() {
|
||||
let drive = FakeDrive::default();
|
||||
let (laptop, catalog) = device("laptop");
|
||||
catalog
|
||||
.lock()
|
||||
.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();
|
||||
|
||||
assert!(!again.uploaded);
|
||||
assert_eq!(drive.uploads(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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();
|
||||
assert!(!report.uploaded);
|
||||
assert!(drive.names().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_file_that_went_missing_is_written_again() {
|
||||
let drive = FakeDrive::default();
|
||||
let (laptop, catalog) = device("laptop");
|
||||
catalog
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
|
||||
.unwrap();
|
||||
laptop.sync_with(&drive, &catalog, 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();
|
||||
|
||||
assert!(report.uploaded);
|
||||
assert_eq!(drive.names(), ["laptop.json"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn history_this_installation_wrote_before_comes_back_into_an_empty_catalog() {
|
||||
let drive = FakeDrive::default();
|
||||
let (laptop, catalog) = device("laptop");
|
||||
catalog
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
|
||||
.unwrap();
|
||||
laptop.sync_with(&drive, &catalog, 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();
|
||||
|
||||
assert_eq!(report.applied, 1);
|
||||
assert!(!report.uploaded);
|
||||
assert_eq!(
|
||||
empty.lock().watch_state("share-a", "ep1").unwrap(),
|
||||
Some(watched_at(60.0, 100))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_own_file_that_cannot_be_read_is_not_overwritten() {
|
||||
let drive = FakeDrive::default();
|
||||
let (laptop, catalog) = device("laptop");
|
||||
catalog
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
|
||||
.unwrap();
|
||||
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
|
||||
|
||||
let (relaunched, other) = device("laptop");
|
||||
other
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep2", &watched_at(5.0, 150))
|
||||
.unwrap();
|
||||
drive
|
||||
.offline
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
let report = relaunched.sync_with(&drive, &other, 200).await.unwrap();
|
||||
assert!(!report.uploaded);
|
||||
|
||||
drive
|
||||
.offline
|
||||
.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
relaunched.sync_with(&drive, &other, 300).await.unwrap();
|
||||
let state = other.lock().all_watch_states().unwrap();
|
||||
assert_eq!(state.len(), 2, "both episodes survive: {state:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_file_from_a_newer_build_is_neither_merged_nor_retired() {
|
||||
let drive = FakeDrive::default();
|
||||
let future = HistoryFile {
|
||||
version: FORMAT_VERSION + 1,
|
||||
installation: "future".to_owned(),
|
||||
entries: vec![entry("ep1", 60.0, 100)],
|
||||
};
|
||||
drive.put("future.json", serde_json::to_vec(&future).unwrap());
|
||||
let (laptop, catalog) = device("laptop");
|
||||
catalog
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep2", &watched_at(5.0, 100))
|
||||
.unwrap();
|
||||
|
||||
let report = laptop
|
||||
.sync_with(&drive, &catalog, RETIRE_AFTER_SECS + DAY)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.applied, 0);
|
||||
assert_eq!(report.retired, 0);
|
||||
assert_eq!(drive.names(), ["future.json", "laptop.json"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_quiet_installations_file_is_retired_once_its_history_is_carried() {
|
||||
let drive = FakeDrive::default();
|
||||
let (old_phone, old_catalog) = device("old-phone");
|
||||
old_catalog
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep1", &watched_at(600.0, 100))
|
||||
.unwrap();
|
||||
drive.at(100);
|
||||
old_phone
|
||||
.sync_with(&drive, &old_catalog, 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();
|
||||
|
||||
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)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
fresh_catalog.lock().watch_state("share-a", "ep1").unwrap(),
|
||||
Some(watched_at(600.0, 100))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_recent_installations_file_is_kept() {
|
||||
let drive = FakeDrive::default();
|
||||
let (laptop, laptop_catalog) = device("laptop");
|
||||
laptop_catalog
|
||||
.lock()
|
||||
.set_watch_state("share-a", "ep1", &watched_at(600.0, 100))
|
||||
.unwrap();
|
||||
drive.at(100);
|
||||
laptop
|
||||
.sync_with(&drive, &laptop_catalog, 100)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (phone, catalog) = device("phone");
|
||||
let report = phone
|
||||
.sync_with(&drive, &catalog, 100 + RETIRE_AFTER_SECS - DAY)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.retired, 0);
|
||||
assert_eq!(drive.names(), ["laptop.json", "phone.json"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_installation_keeps_the_id_it_was_given() {
|
||||
let root =
|
||||
|
||||
+14
-5
@@ -108,9 +108,18 @@ an edge case.
|
||||
session — tokens, the mailbox password that unlocks the key chain, and the
|
||||
key salts a refreshed token can no longer fetch — is one blob in the same
|
||||
credential store, under `proton-account`. Refresh tokens are single-use, so
|
||||
every refresh is written back. The account writes exactly one thing: the
|
||||
watch-history files on its own `proton-stream` device (`pstr-core::sync`).
|
||||
Folders of the account added as shares are only ever read.
|
||||
every refresh is written back. On the desktop the blob can outgrow one
|
||||
credential entry — Windows caps one at 1280 UTF-16 units — so
|
||||
`KeyringSecretStore` splits a long value across `key#0`, `key#1`, … under a
|
||||
header in `key`. The account writes exactly one kind of thing: the
|
||||
watch-history files on its own `proton-stream` device (`pstr-core::sync`),
|
||||
including trashing the file of an installation quiet for 90 days once its
|
||||
history is merged into this one's. Folders of the account added as shares
|
||||
are only ever read.
|
||||
- A CAPTCHA in front of a sign-in is shown in a webview, because the page
|
||||
reports success by posting a message to its host. On the desktop that is a
|
||||
child process of the app (`pstr-app::verify`, wry): WebKitGTK on Linux,
|
||||
WebView2 on Windows. It may only navigate to `https://*.proton.me`.
|
||||
- Signature verification is absent on the visitor path by design — an anonymous
|
||||
visitor cannot read `core/v4/keys/all` to resolve a signer. Decryption still
|
||||
proves the bytes were encrypted to the node key. Nodes come back with nothing
|
||||
@@ -125,10 +134,10 @@ an edge case.
|
||||
| Share list | config dir, `shares.json` | **No** — atomic writes, never overwritten when unparseable |
|
||||
| Share secrets | OS credential store | **No** |
|
||||
| Catalog | data dir, `catalog.db` | Yes — recrawl |
|
||||
| Watch state | same DB, own table | **No** — deliberately survives a recrawl; with an account, mirrored to its Drive |
|
||||
| Watch state | same DB, own table | **No** — deliberately survives a recrawl and removing the share; with an account, mirrored to its Drive |
|
||||
| Proton session | OS credential store, `proton-account` | No — sign in again |
|
||||
| Sync identity | config dir, `sync.json` | A new id if missing — the old history file is still read |
|
||||
| Watch history in Drive | device `proton-stream`, `watch-history/<id>.json` | One file per installation; merged per episode, newer wins |
|
||||
| Watch history in Drive | device `proton-stream`, `watch-history/<id>.json` | One file per installation; merged per episode, newer wins. A file unwritten for 90 days is retired by the next installation to sync, after merging it; a live installation whose file was retired writes it again |
|
||||
| Block cache | cache dir, `blocks/` | Yes — must work correctly after deletion |
|
||||
| Poster thumbnails | cache dir, `thumbs/` | Yes — refetched per file, one small decrypt each; stored scaled to 640 px |
|
||||
| Provider artwork | cache dir, `posters/` | Yes — refetched by URL; stored scaled to 640 px |
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ license=('MIT')
|
||||
# mpv carries libmpv.so plus its headers on Arch, so it is both the runtime and
|
||||
# the build dependency. libsecret is the Secret Service side of the keyring —
|
||||
# the share fragment and custom password never touch shares.json.
|
||||
depends=('mpv' 'libsecret' 'hicolor-icon-theme')
|
||||
depends=('mpv' 'libsecret' 'webkit2gtk-4.1' 'hicolor-icon-theme')
|
||||
makedepends=('cargo')
|
||||
optdepends=('gnome-keyring: credential storage via Secret Service'
|
||||
'kwallet: credential storage via Secret Service on KDE')
|
||||
|
||||
+15
-5
@@ -40,6 +40,7 @@ are not Arch.
|
||||
|---|---|
|
||||
| **libmpv ≥ 0.34** | The player. `pstr-player` links it directly and drives it through `stream_cb`; nothing here shells out to `mpv(1)`, so packages depend on the library, not the player binary. |
|
||||
| **libsecret / Secret Service** | The share URL fragment and the custom password are secrets and live in the OS credential store, never in `shares.json`. |
|
||||
| **WebKitGTK 4.1** | The window Proton's CAPTCHA is shown in when it gates a sign-in (`pstr-app::verify`). The page reports success by posting a message to its host, so the system browser cannot stand in for it. |
|
||||
| **libGL, libxkbcommon, libX11, libwayland-client** | eframe on glow, with both display backends compiled in. |
|
||||
|
||||
Not dependencies, deliberately: **SQLite** (rusqlite is `bundled`) and
|
||||
@@ -47,11 +48,13 @@ Not dependencies, deliberately: **SQLite** (rusqlite is `bundled`) and
|
||||
|
||||
Per packager that is:
|
||||
|
||||
- Debian — `libmpv2 | libmpv1` (trixie | bookworm), `libsecret-1-0`, `libgl1`,
|
||||
`libxkbcommon0`, `libx11-6`, `libwayland-client0`.
|
||||
- RPM — `mpv-libs`, `libsecret`, `mesa-libGL`, `libxkbcommon`, `libX11`,
|
||||
`libwayland-client`.
|
||||
- Arch — `mpv` (which carries both `libmpv.so` and its headers), `libsecret`.
|
||||
- Debian — `libmpv2 | libmpv1` (trixie | bookworm), `libsecret-1-0`,
|
||||
`libwebkit2gtk-4.1-0`, `libgl1`, `libxkbcommon0`, `libx11-6`,
|
||||
`libwayland-client0`.
|
||||
- RPM — `mpv-libs`, `libsecret`, `webkit2gtk4.1`, `mesa-libGL`, `libxkbcommon`,
|
||||
`libX11`, `libwayland-client`.
|
||||
- Arch — `mpv` (which carries both `libmpv.so` and its headers), `libsecret`,
|
||||
`webkit2gtk-4.1`.
|
||||
|
||||
The `.deb` and `.rpm` are built from binaries compiled on the packaging host, so
|
||||
they inherit its glibc. A package built on Arch installs on Fedora rawhide but
|
||||
@@ -88,6 +91,13 @@ There is no static libmpv worth shipping, so **"standalone exe" means the exe
|
||||
plus `libmpv-2.dll` beside it** — that pair, plus `pstr.exe` and the README, is
|
||||
what the portable `.zip` contains and what the `.msi` installs.
|
||||
|
||||
The cross build adds `WebView2Loader.dll` to that set. The CAPTCHA window runs
|
||||
on WebView2, and under `-gnu` `webview2-com-sys` links its loader as a DLL —
|
||||
without it beside the exe, the app does not start at all. `build-windows.sh`
|
||||
takes it from that crate's build output. An `-msvc` build links the loader
|
||||
statically and needs no DLL. The WebView2 *runtime* is part of Windows 10 and
|
||||
11 and is not shipped.
|
||||
|
||||
The MSI needs the WiX toolset; the script installs it (`dotnet tool install
|
||||
--global wix`) and adds the UI extension if they are missing. It installs to
|
||||
`Program Files\Proton Stream`, puts a Start Menu shortcut on the GUI, and adds
|
||||
|
||||
@@ -31,6 +31,7 @@ ExclusiveArch: x86_64
|
||||
# `bundled`) and TLS is rustls, so neither shows up here.
|
||||
Requires: mpv-libs >= 0.34
|
||||
Requires: libsecret
|
||||
Requires: webkit2gtk4.1
|
||||
Requires: mesa-libGL
|
||||
Requires: libxkbcommon
|
||||
Requires: libX11
|
||||
|
||||
@@ -149,6 +149,13 @@
|
||||
<File Id="MpvDll" Source="$(var.MpvDll)" Name="libmpv-2.dll" KeyPath="yes" />
|
||||
</Component>
|
||||
|
||||
<!-- Loads WebView2 for the CAPTCHA window Proton sometimes puts
|
||||
in front of a sign-in. A -gnu build links it as a DLL; the
|
||||
WebView2 runtime itself comes with Windows. -->
|
||||
<Component Id="WebViewLoader" Guid="3e8a89cf-cf3e-4cc7-b1c6-9d272b3210c3" Win64="yes">
|
||||
<File Id="WebViewLoader" Source="$(var.WebViewLoader)" Name="WebView2Loader.dll" KeyPath="yes" />
|
||||
</Component>
|
||||
|
||||
<Component Id="ReadMe" Guid="5bb65b44-f5df-423b-8dcc-a753d7aef565" Win64="yes">
|
||||
<File Id="ReadMe" Source="$(var.ReadMe)" Name="README.md" KeyPath="yes" />
|
||||
</Component>
|
||||
@@ -162,6 +169,7 @@
|
||||
<ComponentRef Id="AppExe" />
|
||||
<ComponentRef Id="CliExe" />
|
||||
<ComponentRef Id="MpvDll" />
|
||||
<ComponentRef Id="WebViewLoader" />
|
||||
<ComponentRef Id="ReadMe" />
|
||||
</Feature>
|
||||
|
||||
|
||||
@@ -232,9 +232,16 @@ stage() {
|
||||
[[ -f "$rel/$f" ]] || die "$f missing from $rel — drop --skip-build"
|
||||
done
|
||||
|
||||
# The CAPTCHA window's WebView2 loader. Under -msvc it links statically;
|
||||
# under -gnu, webview2-com-sys links the DLL, which it ships and copies into
|
||||
# its build output, so the exe does not start without it beside it.
|
||||
local loader
|
||||
loader="$(ls -t "$rel"/build/webview2-com-sys-*/out/x64/WebView2Loader.dll 2>/dev/null | head -n1)"
|
||||
[[ -n "$loader" ]] || die "WebView2Loader.dll missing from $rel/build — drop --skip-build"
|
||||
|
||||
rm -rf "$OUTDIR"
|
||||
mkdir -p "$OUTDIR"
|
||||
install -m755 "$rel/$GUI_BIN" "$rel/$CLI_BIN" "$MPV_DLL" "$OUTDIR/"
|
||||
install -m755 "$rel/$GUI_BIN" "$rel/$CLI_BIN" "$MPV_DLL" "$loader" "$OUTDIR/"
|
||||
install -m644 "$ROOT/README.md" "$OUTDIR/"
|
||||
|
||||
if [[ -n "$SIGN_CERT" ]]; then
|
||||
@@ -295,6 +302,7 @@ make_msi() {
|
||||
-D "Version=$VERSION" \
|
||||
-D "BinDir=$OUTDIR" \
|
||||
-D "MpvDll=$OUTDIR/libmpv-2.dll" \
|
||||
-D "WebViewLoader=$OUTDIR/WebView2Loader.dll" \
|
||||
-D "IconFile=$PKG/proton-stream.ico" \
|
||||
-D "ReadMe=$OUTDIR/README.md" \
|
||||
-o "$out" "$PKG/proton-stream.wixl.wxs"
|
||||
|
||||
+1
-1
@@ -229,7 +229,7 @@ Priority: optional
|
||||
Architecture: $arch
|
||||
Maintainer: Nils Pukropp <contact@narl.io>
|
||||
Installed-Size: $size
|
||||
Depends: libc6, libmpv2 | libmpv1, libsecret-1-0, libgl1, libxkbcommon0, libx11-6, libwayland-client0
|
||||
Depends: libc6, libmpv2 | libmpv1, libsecret-1-0, libwebkit2gtk-4.1-0, libgl1, libxkbcommon0, libx11-6, libwayland-client0
|
||||
Recommends: gnome-keyring
|
||||
Homepage: https://github.com/narrrl/proton-stream
|
||||
Description: Netflix-style desktop client for Proton Drive public links
|
||||
|
||||
Reference in new issue
Block a user