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
+2475
-186
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
|
|||||||
@@ -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 -> {
|
||||||
code,
|
OutlinedTextField(
|
||||||
{ code = it },
|
code,
|
||||||
label = { Text("Two-factor code") },
|
{ code = it },
|
||||||
singleLine = true,
|
label = { Text("Code from your authenticator app") },
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
singleLine = true,
|
||||||
)
|
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 = {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
.to_owned(),
|
.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) => return engine.emit(Event::SignInFailed(error.to_string())),
|
||||||
}
|
}
|
||||||
Err(error) => 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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(())
|
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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+603
-94
@@ -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,59 +303,69 @@ 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 {
|
||||||
for node in drive.enumerate_nodes(&uids).await? {
|
others.push(file);
|
||||||
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
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user