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:
nvrl committed 2026-10-06 14:30:15 +02:00
1 parent 26cccd5c63
commit d9ca8f8d60
28 files changed
+2466 -177

No files matched your search

+3 -3
View File
@@ -23,11 +23,11 @@ jobs:
- uses: Swatinem/rust-cache@v2.9.1 - uses: Swatinem/rust-cache@v2.9.1
# libmpv for pstr-player. # libmpv for pstr-player, WebKitGTK for the CAPTCHA window.
- name: Install libmpv - name: Install libmpv and WebKitGTK
run: | run: |
sudo apt-get update 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 - run: cargo fmt --all -- --check
+1 -1
View File
@@ -76,7 +76,7 @@ jobs:
- name: Install build dependencies - name: Install build dependencies
run: | run: |
sudo apt-get update 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 - name: Gate
run: | run: |
Generated
+1216 -38
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -107,6 +107,12 @@ windows-sys = "0.61"
tauri-winrt-notification = "0.8" tauri-winrt-notification = "0.8"
# The exe's own icon resource, which is what Explorer and a taskbar pin draw. # The exe's own icon resource, which is what Explorer and a taskbar pin draw.
embed-resource = "3" 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" rpassword = "7"
libloading = "0.8" libloading = "0.8"
lru = "0.18" lru = "0.18"
+3 -1
View File
@@ -83,7 +83,9 @@ Rust 2024, MSRV 1.96.
cargo build --release 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 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 `libmpv-2.dll` ships next to the executable. The Proton SDK crates come from
crates.io; nothing here builds against a sibling checkout. 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.NativeMpvHost
import io.narl.protonstream.playback.PlaybackService import io.narl.protonstream.playback.PlaybackService
import io.narl.protonstream.settings.SettingsStore import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.sync.WatchSyncWorker
import io.narl.protonstream.ui.ProtonStreamApp import io.narl.protonstream.ui.ProtonStreamApp
import io.narl.protonstream.ui.theme.AppearanceState import io.narl.protonstream.ui.theme.AppearanceState
import io.narl.protonstream.ui.theme.ProtonStreamTheme import io.narl.protonstream.ui.theme.ProtonStreamTheme
@@ -207,6 +208,9 @@ class MainActivity : ComponentActivity() {
override fun onStop() { override fun onStop() {
super.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 ( if (
!isChangingConfigurations && !isChangingConfigurations &&
!isInPictureInPictureMode && !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 = { text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
when (state) { when (state) {
AccountState.SecondFactor -> OutlinedTextField( AccountState.SecondFactor -> {
OutlinedTextField(
code, code,
{ code = it }, { code = it },
label = { Text("Two-factor code") }, label = { Text("Code from your authenticator app") },
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
) )
Text(SECURITY_KEY_NOTE, style = MaterialTheme.typography.bodySmall)
}
AccountState.MailboxPassword -> { AccountState.MailboxPassword -> {
Text( Text(
"This account has a separate mailbox password. It unlocks your files.", "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. */ /** Installed once per page, at start and again at finish in case the first ran too early. */
private const val VERIFICATION_BRIDGE = """ private const val VERIFICATION_BRIDGE = """
if (!window.__pstrVerification) { if (!window.__pstrVerification) {
@@ -136,15 +136,15 @@ internal fun SharesScreen(
) )
} }
removing?.let { share -> removing?.let { share ->
// Asked, because it cannot be undone: the link's secret, its offline // Asked, because it cannot be undone: the link's secret and its
// files and its watch positions all go (`Catalog::remove_share`). // offline files go. Watch positions stay (`Catalog::remove_share`).
AlertDialog( AlertDialog(
onDismissRequest = { removing = null }, onDismissRequest = { removing = null },
title = { Text("Remove ${share.name}?") }, title = { Text("Remove ${share.name}?") },
text = { text = {
Text( Text(
"Its titles leave the library, and its offline episodes and watch " + "Its titles leave the library and its offline episodes are deleted. " +
"progress are deleted. Adding the link again starts it fresh.", "Watch progress is kept, so adding the link again picks up where you left off.",
) )
}, },
confirmButton = { confirmButton = {
+2 -1
View File
@@ -3809,11 +3809,12 @@ mod tests {
assert!(engine.remove_share(share.id.clone()).is_err()); assert!(engine.remove_share(share.id.clone()).is_err());
assert_eq!(engine.share_generation.load(Ordering::Acquire), 2); assert_eq!(engine.share_generation.load(Ordering::Acquire), 2);
assert!(engine.shares().expect("shares").is_empty()); assert!(engine.shares().expect("shares").is_empty());
// Kept, as through a recrawl: sync would bring it back regardless.
assert!( assert!(
engine engine
.watch_state(share.id, "episode".to_owned()) .watch_state(share.id, "episode".to_owned())
.expect("watch state") .expect("watch state")
.is_none() .is_some()
); );
drop(engine); drop(engine);
+2
View File
@@ -33,6 +33,8 @@ serde_json.workspace = true
directories.workspace = true directories.workspace = true
chrono.workspace = true chrono.workspace = true
souvlaki.workspace = true souvlaki.workspace = true
wry.workspace = true
tao.workspace = true
[build-dependencies] [build-dependencies]
embed-resource.workspace = true embed-resource.workspace = true
+3
View File
@@ -2118,6 +2118,9 @@ impl eframe::App for App {
self.playback = None; self.playback = None;
self.engine self.engine
.shutdown_downloads(std::time::Duration::from_secs(3)); .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));
} }
} }
+50 -7
View File
@@ -35,6 +35,7 @@ use pstr_core::library::{Library, Title, TitleKind};
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId}; use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId};
use pstr_core::prefs::PlaybackPrefs; use pstr_core::prefs::PlaybackPrefs;
use pstr_core::proton_drive_rs::ThumbnailType; 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::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use pstr_core::shares::AccountFolder; use pstr_core::shares::AccountFolder;
use pstr_core::sync::{SyncReport, WatchSync}; use pstr_core::sync::{SyncReport, WatchSync};
@@ -704,17 +705,38 @@ impl Engine {
pub fn sign_in(&self, username: String, password: String) { pub fn sign_in(&self, username: String, password: String) {
let engine = self.clone(); let engine = self.clone();
self.runtime.spawn(async move { self.runtime.spawn(async move {
match engine.accounts.sign_in(&username, &password, None).await { let mut verification = None;
Ok(step) => engine.sign_in_step(step), // Twice at most: once as asked, and once more with a solved
Err(pstr_core::Error::HumanVerification(_)) => { // CAPTCHA if Proton wanted one. A second gate after that is
engine.emit(Event::SignInFailed( // Proton refusing the answer, not a puzzle worth repeating.
"Proton wants a CAPTCHA before it accepts this sign-in, and this app \ for _ in 0..2 {
cannot show one. Try again later, or sign in once from the Android app." 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(), .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) { fn sync_periodically(&self) {
let engine = self.clone(); let engine = self.clone();
self.runtime.spawn(async move { self.runtime.spawn(async move {
+1
View File
@@ -30,5 +30,6 @@ pub mod pacing;
pub mod playback; pub mod playback;
pub mod theme; pub mod theme;
pub mod ui; pub mod ui;
pub mod verify;
pub mod video; pub mod video;
pub mod window; pub mod window;
+6
View File
@@ -16,6 +16,12 @@ use anyhow::Context;
use pstr_core::config::AppDirs; use pstr_core::config::AppDirs;
fn main() -> anyhow::Result<()> { 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() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env() tracing_subscriber::EnvFilter::try_from_default_env()
+6
View File
@@ -207,6 +207,12 @@ fn code_form(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Acti
.desired_width(160.0), .desired_width(160.0),
); );
field.request_focus(); 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); ui.add_space(theme::space::L);
error_line(ui, panel); error_line(ui, panel);
let ready = !panel.code.trim().is_empty() && !panel.busy; let ready = !panel.code.trim().is_empty() && !panel.busy;
+190
View File
@@ -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"));
}
}
+6 -4
View File
@@ -1084,7 +1084,6 @@ async fn play(
Ok(()) Ok(())
} }
/// Unix seconds, for the "when was this asked" columns.
/// Walk an interactive sign-in through whatever the account asks for. /// Walk an interactive sign-in through whatever the account asks for.
async fn login(accounts: &AccountStore, username: &str) -> Result<()> { async fn login(accounts: &AccountStore, username: &str) -> Result<()> {
let password = read_password("Password: ")?; let password = read_password("Password: ")?;
@@ -1092,10 +1091,12 @@ async fn login(accounts: &AccountStore, username: &str) -> Result<()> {
Ok(step) => step, Ok(step) => step,
Err(pstr_core::Error::HumanVerification(challenge)) => { Err(pstr_core::Error::HumanVerification(challenge)) => {
// Solving a CAPTCHA needs a browser engine that can hand the // 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!( anyhow::bail!(
"Proton wants a CAPTCHA before it accepts this sign-in. Sign in once in the \ "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() challenge.verification_url()
); );
} }
@@ -1108,7 +1109,8 @@ async fn login(accounts: &AccountStore, username: &str) -> Result<()> {
return Ok(()); return Ok(());
} }
SignIn::SecondFactor(pending) => { 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 pending
.second_factor(&code) .second_factor(&code)
.await .await
+39 -9
View File
@@ -513,19 +513,17 @@ impl Catalog {
Ok(rows.collect::<std::result::Result<_, _>>()?) 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 /// Watch state stays, as through a recrawl. It is history, and not only
/// state alone — this is the viewer saying they are done with the share. /// this device's: with an account signed in, the next sync would bring it
/// Keeping positions for files they can no longer reach would only leave /// straight back from the other devices' files, so deleting it here would
/// the "continue watching" row pointing at nothing. /// 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<()> { pub fn remove_share(&mut self, share_id: &str) -> Result<()> {
let tx = self.conn.transaction()?; let tx = self.conn.transaction()?;
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?; 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( tx.execute(
"DELETE FROM offline_files WHERE share_id = ?1", "DELETE FROM offline_files WHERE share_id = ?1",
params![share_id], params![share_id],
@@ -2430,6 +2428,38 @@ mod tests {
assert_eq!(rows[0].link_id, "film"); 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] #[test]
fn merging_takes_only_states_newer_than_the_recorded_ones() { fn merging_takes_only_states_newer_than_the_recorded_ones() {
let mut catalog = Catalog::in_memory().expect("open"); let mut catalog = Catalog::in_memory().expect("open");
+208 -2
View File
@@ -55,19 +55,26 @@ pub trait SecretStore: Send + Sync {
} }
/// The desktop OS credential store. /// The desktop OS credential store.
///
/// A value too long for one entry is split across several; see
/// [`ENTRY_UNITS`].
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct KeyringSecretStore; pub struct KeyringSecretStore;
/// One credential-store entry per key, with no splitting.
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
impl KeyringSecretStore { struct KeyringEntries;
#[cfg(not(target_os = "android"))]
impl KeyringEntries {
fn entry(key: &str) -> Result<keyring::Entry> { fn entry(key: &str) -> Result<keyring::Entry> {
Ok(keyring::Entry::new(KEYRING_SERVICE, key)?) Ok(keyring::Entry::new(KEYRING_SERVICE, key)?)
} }
} }
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
impl SecretStore for KeyringSecretStore { impl SecretStore for KeyringEntries {
fn set(&self, key: &str, value: &str) -> Result<()> { fn set(&self, key: &str, value: &str) -> Result<()> {
Self::entry(key)?.set_password(value)?; Self::entry(key)?.set_password(value)?;
Ok(()) 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. /// What the app records about a share, minus its secrets.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Share { pub struct Share {
+601 -92
View File
@@ -32,7 +32,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use parking_lot::Mutex; use parking_lot::Mutex;
use proton_drive_rs::{DeviceType, NodeKind}; use proton_drive_rs::{DeviceType, NodeKind, ProtonDriveClient};
use proton_sdk::ids::NodeUid; use proton_sdk::ids::NodeUid;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -120,12 +120,133 @@ pub struct SyncReport {
pub uploaded: bool, pub uploaded: bool,
/// Other installations whose history was read. /// Other installations whose history was read.
pub devices: usize, 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 { struct Seen {
revision: Option<String>, revision: Option<String>,
entries: Vec<HistoryEntry>, entries: Option<Vec<HistoryEntry>>,
} }
/// Syncs the catalog's watch table with the account's Drive. /// 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. /// the catalog so an unchanged history is not uploaded as a new revision.
own: Mutex<Option<Vec<HistoryEntry>>>, own: Mutex<Option<Vec<HistoryEntry>>>,
/// The history folders, resolved once per account. Usually one; see /// The history folders, resolved once per account. Usually one; see
/// [`WatchSync::folders`]. /// [`HistoryDrive::history_folders`].
folders: Mutex<Option<Vec<NodeUid>>>, folders: Mutex<Option<Vec<NodeUid>>>,
/// One sync at a time: two would race on creating the device and the /// One sync at a time: two would race on creating the device and the
/// installation's file. /// installation's file.
@@ -182,60 +303,70 @@ impl WatchSync {
*self.folders.lock() = None; *self.folders.lock() = None;
} }
/// Pull every other installation's history into `catalog`, then write /// Pull every other installation's history into `catalog`, write this
/// this one's if it changed. /// 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 /// The catalog is locked only around its own reads and writes, never
/// across a request, so playback can keep saving its position while this /// across a request, so playback can keep saving its position while this
/// runs. /// runs.
pub async fn sync(&self, account: &Account, catalog: &Mutex<Catalog>) -> Result<SyncReport> { 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 _running = self.running.lock().await;
let drive = account.drive(); let folders = self.folders(drive).await?;
let folders = self.folders(account).await?;
let own_name = format!("{}.json", self.installation); let own_name = format!("{}.json", self.installation);
let mut own_file = None; let mut own_file = None;
let mut remote = Vec::new(); let mut others = Vec::new();
let mut devices = 0;
for folder in &folders { for folder in &folders {
let uids = drive.enumerate_folder_children_node_uids(folder).await?; for file in drive.list(folder).await? {
if uids.is_empty() { if file.name == own_name {
continue; 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() { if self.own.lock().is_none() {
let entries = match &own_file { let entries = match &own_file {
Some((uid, revision)) => self.read(account, uid, revision.clone()).await, Some(file) => self.read(drive, file).await,
None => Vec::new(), None => Some(Vec::new()),
}; };
// What this installation wrote before is history too: after a match entries {
// reinstall that kept the config, or a catalog that was deleted. Some(entries) => {
remote.extend(entries.iter().cloned()); remote.extend(entries.iter().cloned());
*self.own.lock() = Some(entries); *self.own.lock() = Some(entries);
} }
None => own_unreadable = true,
}
}
let incoming: Vec<(String, String, WatchState)> = remote let incoming: Vec<(String, String, WatchState)> = remote
.iter() .iter()
@@ -254,8 +385,12 @@ impl WatchSync {
.collect(); .collect();
entries.sort_by(|a, b| (&a.share, &a.link).cmp(&(&b.share, &b.link))); entries.sort_by(|a, b| (&a.share, &a.link).cmp(&(&b.share, &b.link)));
let unchanged = self.own.lock().as_ref() == Some(&entries); // A file that is missing is written even when what it would hold has
let uploaded = if unchanged || (entries.is_empty() && own_file.is_none()) { // 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 false
} else { } else {
let file = HistoryFile { let file = HistoryFile {
@@ -266,94 +401,91 @@ impl WatchSync {
let bytes = serde_json::to_vec(&file) let bytes = serde_json::to_vec(&file)
.map_err(|e| Error::Config(format!("serialize watch history: {e}")))?; .map_err(|e| Error::Config(format!("serialize watch history: {e}")))?;
match &own_file { match &own_file {
Some((uid, _)) => drive.upload_new_revision(uid, &bytes).await?, Some(file) => drive.replace(&file.uid, &bytes).await?,
None => { None => drive.create(&folders[0], &own_name, &bytes).await?,
drive
.upload_file(&folders[0], &own_name, "application/json", &bytes)
.await?;
}
} }
*self.own.lock() = Some(entries); *self.own.lock() = Some(entries);
true 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 { Ok(SyncReport {
applied, applied,
uploaded, uploaded,
devices, devices: others.len(),
retired: retired.len(),
}) })
} }
/// The entries of one history file, from the cache when its revision has /// 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 /// A file that will not download or parse is logged and skipped: it is
/// is another device's, and one bad file must not stop the rest syncing. /// another device's, and one bad file must not stop the rest syncing.
async fn read( async fn read<D: HistoryDrive>(
&self, &self,
account: &Account, drive: &D,
uid: &NodeUid, file: &RemoteFile,
revision: Option<String>, ) -> Option<Vec<HistoryEntry>> {
) -> Vec<HistoryEntry> { let key = file.uid.link_id.as_str().to_owned();
let key = uid.link_id.as_str().to_owned();
if let Some(seen) = self.seen.lock().get(&key) if let Some(seen) = self.seen.lock().get(&key)
&& revision.is_some() && file.revision.is_some()
&& seen.revision == revision && seen.revision == file.revision
{ {
return seen.entries.clone(); return seen.entries.clone();
} }
let entries = match account.drive().download_file(uid).await { let uid = &file.uid;
Ok(bytes) => match parse(&bytes) { let entries = match drive.download(uid).await {
Some(entries) => entries, Ok(bytes) => {
None => { let entries = parse(&bytes);
if entries.is_none() {
tracing::warn!(%uid, "skipping a watch-history file this build cannot read"); tracing::warn!(%uid, "skipping a watch-history file this build cannot read");
Vec::new()
} }
}, entries
}
Err(error) => { 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"); tracing::warn!(%uid, %error, "a watch-history file could not be downloaded");
return Vec::new(); return None;
} }
}; };
self.seen.lock().insert( self.seen.lock().insert(
key, key,
Seen { Seen {
revision, revision: file.revision.clone(),
entries: entries.clone(), entries: entries.clone(),
}, },
); );
entries entries
} }
/// The history folders, creating the device and folder the first time. /// The history folders, resolved once per account.
/// async fn folders<D: HistoryDrive>(&self, drive: &D) -> Result<Vec<NodeUid>> {
/// Two installations signing in at the same moment can each create the
/// device before seeing the other's. Every device of that name is read
/// from so neither's history is lost; the oldest is the one written to,
/// which is the same one on every installation.
async fn folders(&self, account: &Account) -> Result<Vec<NodeUid>> {
if let Some(folders) = self.folders.lock().clone() { if let Some(folders) = self.folders.lock().clone() {
return Ok(folders); return Ok(folders);
} }
let drive = account.drive(); let folders = drive.history_folders().await?;
let mut devices: Vec<_> = drive
.enumerate_devices()
.await?
.into_iter()
.filter(|device| device.name.as_deref().ok() == Some(DEVICE_NAME))
.collect();
devices.sort_by_key(|device| device.creation_time);
if devices.is_empty() {
devices.push(drive.create_device(DEVICE_NAME, device_type()).await?);
}
let mut folders = Vec::with_capacity(devices.len());
for device in &devices {
folders.push(
drive
.create_folder_path(&device.root_folder_uid, FOLDER)
.await?,
);
}
*self.folders.lock() = Some(folders.clone()); *self.folders.lock() = Some(folders.clone());
Ok(folders) 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] #[test]
fn an_installation_keeps_the_id_it_was_given() { fn an_installation_keeps_the_id_it_was_given() {
let root = let root =
+14 -5
View File
@@ -108,9 +108,18 @@ an edge case.
session — tokens, the mailbox password that unlocks the key chain, and the 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 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 credential store, under `proton-account`. Refresh tokens are single-use, so
every refresh is written back. The account writes exactly one thing: the every refresh is written back. On the desktop the blob can outgrow one
watch-history files on its own `proton-stream` device (`pstr-core::sync`). credential entry — Windows caps one at 1280 UTF-16 units — so
Folders of the account added as shares are only ever read. `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 - 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 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 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 list | config dir, `shares.json` | **No** — atomic writes, never overwritten when unparseable |
| Share secrets | OS credential store | **No** | | Share secrets | OS credential store | **No** |
| Catalog | data dir, `catalog.db` | Yes — recrawl | | 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 | | 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 | | 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 | | 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 | | 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 | | Provider artwork | cache dir, `posters/` | Yes — refetched by URL; stored scaled to 640 px |
+1 -1
View File
@@ -23,7 +23,7 @@ license=('MIT')
# mpv carries libmpv.so plus its headers on Arch, so it is both the runtime and # 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 build dependency. libsecret is the Secret Service side of the keyring —
# the share fragment and custom password never touch shares.json. # 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') makedepends=('cargo')
optdepends=('gnome-keyring: credential storage via Secret Service' optdepends=('gnome-keyring: credential storage via Secret Service'
'kwallet: credential storage via Secret Service on KDE') 'kwallet: credential storage via Secret Service on KDE')
+15 -5
View File
@@ -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. | | **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`. | | **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. | | **libGL, libxkbcommon, libX11, libwayland-client** | eframe on glow, with both display backends compiled in. |
Not dependencies, deliberately: **SQLite** (rusqlite is `bundled`) and Not dependencies, deliberately: **SQLite** (rusqlite is `bundled`) and
@@ -47,11 +48,13 @@ Not dependencies, deliberately: **SQLite** (rusqlite is `bundled`) and
Per packager that is: Per packager that is:
- Debian — `libmpv2 | libmpv1` (trixie | bookworm), `libsecret-1-0`, `libgl1`, - Debian — `libmpv2 | libmpv1` (trixie | bookworm), `libsecret-1-0`,
`libxkbcommon0`, `libx11-6`, `libwayland-client0`. `libwebkit2gtk-4.1-0`, `libgl1`, `libxkbcommon0`, `libx11-6`,
- RPM — `mpv-libs`, `libsecret`, `mesa-libGL`, `libxkbcommon`, `libX11`, `libwayland-client0`.
`libwayland-client`. - RPM — `mpv-libs`, `libsecret`, `webkit2gtk4.1`, `mesa-libGL`, `libxkbcommon`,
- Arch — `mpv` (which carries both `libmpv.so` and its headers), `libsecret`. `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 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 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 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. 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 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 --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 `Program Files\Proton Stream`, puts a Start Menu shortcut on the GUI, and adds
+1
View File
@@ -31,6 +31,7 @@ ExclusiveArch: x86_64
# `bundled`) and TLS is rustls, so neither shows up here. # `bundled`) and TLS is rustls, so neither shows up here.
Requires: mpv-libs >= 0.34 Requires: mpv-libs >= 0.34
Requires: libsecret Requires: libsecret
Requires: webkit2gtk4.1
Requires: mesa-libGL Requires: mesa-libGL
Requires: libxkbcommon Requires: libxkbcommon
Requires: libX11 Requires: libX11
+8
View File
@@ -149,6 +149,13 @@
<File Id="MpvDll" Source="$(var.MpvDll)" Name="libmpv-2.dll" KeyPath="yes" /> <File Id="MpvDll" Source="$(var.MpvDll)" Name="libmpv-2.dll" KeyPath="yes" />
</Component> </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"> <Component Id="ReadMe" Guid="5bb65b44-f5df-423b-8dcc-a753d7aef565" Win64="yes">
<File Id="ReadMe" Source="$(var.ReadMe)" Name="README.md" KeyPath="yes" /> <File Id="ReadMe" Source="$(var.ReadMe)" Name="README.md" KeyPath="yes" />
</Component> </Component>
@@ -162,6 +169,7 @@
<ComponentRef Id="AppExe" /> <ComponentRef Id="AppExe" />
<ComponentRef Id="CliExe" /> <ComponentRef Id="CliExe" />
<ComponentRef Id="MpvDll" /> <ComponentRef Id="MpvDll" />
<ComponentRef Id="WebViewLoader" />
<ComponentRef Id="ReadMe" /> <ComponentRef Id="ReadMe" />
</Feature> </Feature>
+9 -1
View File
@@ -232,9 +232,16 @@ stage() {
[[ -f "$rel/$f" ]] || die "$f missing from $rel — drop --skip-build" [[ -f "$rel/$f" ]] || die "$f missing from $rel — drop --skip-build"
done 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" rm -rf "$OUTDIR"
mkdir -p "$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/" install -m644 "$ROOT/README.md" "$OUTDIR/"
if [[ -n "$SIGN_CERT" ]]; then if [[ -n "$SIGN_CERT" ]]; then
@@ -295,6 +302,7 @@ make_msi() {
-D "Version=$VERSION" \ -D "Version=$VERSION" \
-D "BinDir=$OUTDIR" \ -D "BinDir=$OUTDIR" \
-D "MpvDll=$OUTDIR/libmpv-2.dll" \ -D "MpvDll=$OUTDIR/libmpv-2.dll" \
-D "WebViewLoader=$OUTDIR/WebView2Loader.dll" \
-D "IconFile=$PKG/proton-stream.ico" \ -D "IconFile=$PKG/proton-stream.ico" \
-D "ReadMe=$OUTDIR/README.md" \ -D "ReadMe=$OUTDIR/README.md" \
-o "$out" "$PKG/proton-stream.wixl.wxs" -o "$out" "$PKG/proton-stream.wixl.wxs"
+1 -1
View File
@@ -229,7 +229,7 @@ Priority: optional
Architecture: $arch Architecture: $arch
Maintainer: Nils Pukropp <contact@narl.io> Maintainer: Nils Pukropp <contact@narl.io>
Installed-Size: $size 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 Recommends: gnome-keyring
Homepage: https://github.com/narrrl/proton-stream Homepage: https://github.com/narrrl/proton-stream
Description: Netflix-style desktop client for Proton Drive public links Description: Netflix-style desktop client for Proton Drive public links