feat(core,stream,meta,app,android,cli): sign in to Proton and sync watch history

A Proton account is now optional alongside public links. Signing in (password,
then a second-factor code and a mailbox password where the account has them;
a CAPTCHA gate is solved in the Android app) stores one session blob in the OS
credential store under `proton-account`, writing back every refreshed token
since refresh tokens are single-use. With an account, folders of the viewer's
own Drive — My Files, other devices, shared with me — can be added as shares
through the same `ShareClient` surface as a public link.

Watch history syncs through a `proton-stream` device in that Drive: one
`watch-history/<installation>.json` per installation, so no two devices ever
write the same file. Each sync reads the others and merges per episode, newer
`updated_at` wins, with a deterministic tie-break. It runs at launch, after
sign-in, whenever the player closes and every five minutes. A session that can
no longer refresh signs the app out instead of failing on every launch.

The CLI gains `login`, `logout`, `account`, `browse` and `sync`.

Also in this change:
- Android offline downloads no longer look stuck (B64): two transfer at once,
  through a copy stream with no read-ahead, with a smoothed transfer rate.
- Choosing a metadata match by hand now enriches the title too, instead of
  keeping the old match's episodes and artwork.

The catalog schema is unchanged.
This commit is contained in:
nvrl committed 2026-10-06 13:06:54 +02:00
1 parent 6cdab2fde1
commit 0b2813cd42
37 files changed
+4088 -136

No files matched your search

Generated
+4
View File
@@ -4376,6 +4376,7 @@ name = "pstr-android"
version = "1.1.3" version = "1.1.3"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"futures",
"jni 0.21.1", "jni 0.21.1",
"log", "log",
"parking_lot", "parking_lot",
@@ -4428,6 +4429,7 @@ version = "1.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
"parking_lot",
"proton-drive-rs", "proton-drive-rs",
"proton-sdk", "proton-sdk",
"pstr-core", "pstr-core",
@@ -4446,7 +4448,9 @@ version = "1.1.3"
dependencies = [ dependencies = [
"directories", "directories",
"futures", "futures",
"getrandom 0.3.4",
"keyring", "keyring",
"parking_lot",
"proton-drive-rs", "proton-drive-rs",
"proton-sdk", "proton-sdk",
"rusqlite", "rusqlite",
+2
View File
@@ -54,6 +54,8 @@ directories = "5"
# Local dates for the history page. Already in the tree through the SDK. # Local dates for the history page. Already in the tree through the SDK.
chrono = { version = "0.4", default-features = false, features = ["clock"] } chrono = { version = "0.4", default-features = false, features = ["clock"] }
sha2 = "0.10" sha2 = "0.10"
# The id a watch-history file is named by. Already in the tree through the SDK.
getrandom = "0.3"
# `bundled` so a Windows build needs no system SQLite. # `bundled` so a Windows build needs no system SQLite.
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled"] }
# The share URL fragment and the custom password are both secrets; they go in # The share URL fragment and the custom password are both secrets; they go in
@@ -13,6 +13,8 @@ data class RetainedDownload(
val total: Long = 0, val total: Long = 0,
val status: String = STATUS_QUEUED, val status: String = STATUS_QUEUED,
val error: String? = null, val error: String? = null,
/** Recent transfer rate while running; 0 when unknown or not running. */
val bytesPerSecond: Long = 0,
) { ) {
val key: String get() = "$shareId\u001f$linkId" val key: String get() = "$shareId\u001f$linkId"
@@ -99,6 +101,7 @@ class DownloadStateStore(context: Context) {
.put("total", record.total) .put("total", record.total)
.put("status", record.status) .put("status", record.status)
.put("error", record.error) .put("error", record.error)
.put("rate", record.bytesPerSecond)
.toString() .toString()
private fun decode(encoded: String): RetainedDownload { private fun decode(encoded: String): RetainedDownload {
@@ -112,6 +115,7 @@ class DownloadStateStore(context: Context) {
total = json.optLong("total"), total = json.optLong("total"),
status = json.optString("status", RetainedDownload.STATUS_QUEUED), status = json.optString("status", RetainedDownload.STATUS_QUEUED),
error = json.optString("error").takeIf(String::isNotBlank), error = json.optString("error").takeIf(String::isNotBlank),
bytesPerSecond = json.optLong("rate"),
) )
} }
@@ -25,20 +25,24 @@ class OfflineDownloadWorker(
) : CoroutineWorker(appContext, parameters) { ) : CoroutineWorker(appContext, parameters) {
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
createNotificationChannel() createNotificationChannel()
setForeground(createForegroundInfo(0)) setForeground(createForegroundInfo(null))
val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share")) val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share"))
val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume")) val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume"))
val linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link")) val linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link"))
val label = inputData.getString(KEY_LABEL) ?: linkId val label = inputData.getString(KEY_LABEL) ?: linkId
val store = DownloadStateStore(applicationContext) val store = DownloadStateStore(applicationContext)
// Queued, not running: the engine lets two downloads transfer at once
// and holds the rest back, and only the first progress report means
// this one has a slot.
var retained = store.get(shareId, linkId) var retained = store.get(shareId, linkId)
?: RetainedDownload(shareId, volumeId, linkId, label) ?: RetainedDownload(shareId, volumeId, linkId, label)
retained = retained.copy(status = RetainedDownload.STATUS_RUNNING, error = null) retained = retained.copy(status = RetainedDownload.STATUS_QUEUED, error = null, bytesPerSecond = 0)
store.put(retained) store.put(retained)
val observer = object : DownloadObserver { val observer = object : DownloadObserver {
private var reportedAt = 0L private var reportedAt = 0L
private var reportedPercent = -1 private var reportedPercent = -1
private val rate = TransferRate()
/** /**
* Called once per 4 MiB block — a dozen times a second on a fast * Called once per 4 MiB block — a dozen times a second on a fast
@@ -51,6 +55,7 @@ class OfflineDownloadWorker(
val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt() val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt()
val now = SystemClock.elapsedRealtime() val now = SystemClock.elapsedRealtime()
val complete = total > 0UL && downloaded >= total val complete = total > 0UL && downloaded >= total
val bytesPerSecond = rate.sample(downloaded.toLong(), now)
if (!complete && percent == reportedPercent && now - reportedAt < PROGRESS_INTERVAL_MS) { if (!complete && percent == reportedPercent && now - reportedAt < PROGRESS_INTERVAL_MS) {
return return
} }
@@ -66,6 +71,7 @@ class OfflineDownloadWorker(
current.copy( current.copy(
downloaded = downloaded.toLong(), downloaded = downloaded.toLong(),
total = total.toLong(), total = total.toLong(),
bytesPerSecond = bytesPerSecond,
status = when (current.status) { status = when (current.status) {
RetainedDownload.STATUS_PAUSED, RetainedDownload.STATUS_PAUSED,
RetainedDownload.STATUS_CANCELLED, RetainedDownload.STATUS_CANCELLED,
@@ -114,11 +120,14 @@ class OfflineDownloadWorker(
) )
} }
private fun createForegroundInfo(progress: Int): ForegroundInfo { /** [progress] is a percentage, or null while waiting for a download slot. */
private fun createForegroundInfo(progress: Int?): ForegroundInfo {
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground) .setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
.setContentTitle("Making episode available offline") .setContentTitle(
.setProgress(100, progress, progress == 0) if (progress == null) "Waiting to download" else "Making episode available offline",
)
.setProgress(100, progress ?: 0, progress == null || progress == 0)
.setOngoing(true) .setOngoing(true)
.build() .build()
return ForegroundInfo( return ForegroundInfo(
@@ -146,5 +155,35 @@ class OfflineDownloadWorker(
} }
} }
/**
* Bytes per second over roughly the last few seconds.
*
* Progress arrives once per 4 MiB block, so on a slow link the bar moves in
* steps seconds apart; a rate beside it is what tells a slow download from a
* stuck one. Smoothed, because one block that took a little longer than the
* last would otherwise halve the figure for a moment.
*/
internal class TransferRate {
private var lastBytes = -1L
private var lastAt = 0L
private var smoothed = 0.0
/** Record [bytes] transferred at [now] (milliseconds) and return the rate. */
fun sample(bytes: Long, now: Long): Long {
if (lastBytes < 0 || bytes < lastBytes) {
lastBytes = bytes
lastAt = now
return smoothed.toLong()
}
val elapsed = now - lastAt
if (elapsed <= 0) return smoothed.toLong()
val instant = (bytes - lastBytes) * 1000.0 / elapsed
smoothed = if (smoothed > 0.0) 0.7 * smoothed + 0.3 * instant else instant
lastBytes = bytes
lastAt = now
return smoothed.toLong()
}
}
internal fun stableNotificationId(workId: UUID): Int = internal fun stableNotificationId(workId: UUID): Int =
(workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(1) (workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(1)
@@ -0,0 +1,496 @@
package io.narl.protonstream.ui
import android.annotation.SuppressLint
import android.os.Handler
import android.os.Looper
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Computer
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.FolderShared
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.filled.Logout
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.TonalButton
import uniffi.pstr_android.AccountState
import uniffi.pstr_android.DriveEntryRecord
import uniffi.pstr_android.DrivePlaceRecord
import uniffi.pstr_android.PlaceType
import uniffi.pstr_android.verificationToken
/** What the account card and its dialogs can ask the model for. */
internal data class AccountActions(
val signIn: (String, String, String?) -> Unit,
val submitSecondFactor: (String) -> Unit,
val submitMailboxPassword: (String) -> Unit,
val cancelSignIn: () -> Unit,
val dismissVerification: () -> Unit,
val signOut: () -> Unit,
val syncNow: () -> Unit,
val addFolder: (String, String, String) -> Unit,
val onError: (Throwable) -> Unit,
)
/**
* The Proton account, at the top of the shares page: why one would sign in,
* or who is signed in and how watch-history sync is doing.
*/
@Composable
internal fun AccountCard(
account: AccountUiState,
actions: AccountActions,
onSignIn: () -> Unit,
onBrowse: () -> Unit,
) {
val state = account.state ?: return
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
if (state is AccountState.SignedIn) {
var menu by remember { mutableStateOf(false) }
ListItem(
headlineContent = { Text(state.username, fontWeight = FontWeight.SemiBold) },
supportingContent = { Text(syncLine(account, System.currentTimeMillis())) },
leadingContent = {
Icon(Icons.Default.AccountCircle, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
},
trailingContent = {
Box {
IconButton(onClick = { menu = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "Account actions")
}
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
DropdownMenuItem(
text = { Text("Sync now") },
leadingIcon = { Icon(Icons.Default.Sync, contentDescription = null) },
onClick = { menu = false; actions.syncNow() },
)
DropdownMenuItem(
text = { Text("Sign out") },
leadingIcon = { Icon(Icons.Default.Logout, contentDescription = null) },
onClick = { menu = false; actions.signOut() },
)
}
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
TonalButton(
onClick = onBrowse,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
) {
Icon(Icons.Default.Folder, contentDescription = null)
Text("Add from your Drive", Modifier.padding(start = 8.dp))
}
} else {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Proton account", style = MaterialTheme.typography.titleMedium)
Text(
"Optional. Signed in, watch history is kept in your own Drive so every device " +
"resumes where another left off, and folders of your Drive can join the library.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TonalButton(onClick = onSignIn) { Text("Sign in") }
}
}
}
}
/** "Synced 3 min ago · 2 from other devices", or why it did not. */
internal fun syncLine(account: AccountUiState, now: Long): String {
account.syncError?.let { return "Watch history did not sync: $it" }
val at = account.syncedAt ?: return "Watch history syncs with your Drive"
val minutes = ((now - at).coerceAtLeast(0) / 60_000).toInt()
val ago = when {
minutes < 1 -> "just now"
minutes < 60 -> "$minutes min ago"
else -> "${minutes / 60} h ago"
}
val applied = if (account.applied > 0) " · ${account.applied} from other devices" else ""
return "Synced $ago$applied"
}
/**
* Signing in, one step at a time: address and password, then a second-factor
* code or the mailbox password if the account has them, with Proton's CAPTCHA
* over the top when it asks for one.
*/
@Composable
internal fun SignInDialog(account: AccountUiState, actions: AccountActions, onDismiss: () -> Unit) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var code by remember { mutableStateOf("") }
var mailbox by remember { mutableStateOf("") }
val state = account.state
LaunchedEffect(state) {
if (state is AccountState.SignedIn) onDismiss()
}
val dismiss = {
if (state == AccountState.SecondFactor || state == AccountState.MailboxPassword) actions.cancelSignIn()
onDismiss()
}
AlertDialog(
onDismissRequest = dismiss,
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
title = { Text("Sign in to Proton") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
when (state) {
AccountState.SecondFactor -> OutlinedTextField(
code,
{ code = it },
label = { Text("Two-factor code") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
)
AccountState.MailboxPassword -> {
Text(
"This account has a separate mailbox password. It unlocks your files.",
style = MaterialTheme.typography.bodySmall,
)
PasswordField(mailbox, { mailbox = it }, "Mailbox password")
}
else -> {
OutlinedTextField(
username,
{ username = it },
label = { Text("Address") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
)
PasswordField(password, { password = it }, "Password")
}
}
account.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (account.busy) CircularProgressIndicator(Modifier.padding(top = 4.dp))
}
},
confirmButton = {
when (state) {
AccountState.SecondFactor -> AccentButton(
onClick = { actions.submitSecondFactor(code.trim()) },
enabled = code.isNotBlank() && !account.busy,
) { Text("Continue") }
AccountState.MailboxPassword -> AccentButton(
onClick = { actions.submitMailboxPassword(mailbox) },
enabled = mailbox.isNotEmpty() && !account.busy,
) { Text("Unlock") }
else -> AccentButton(
onClick = { actions.signIn(username.trim(), password, null) },
enabled = username.isNotBlank() && password.isNotEmpty() && !account.busy,
) { Text("Sign in") }
}
},
dismissButton = { TonalButton(onClick = dismiss) { Text("Cancel") } },
)
account.verificationUrl?.let { url ->
VerificationDialog(
url = url,
onToken = { token -> actions.signIn(username.trim(), password, token) },
onDismiss = actions.dismissVerification,
)
}
}
@Composable
private fun PasswordField(value: String, onChange: (String) -> Unit, label: String) = OutlinedTextField(
value,
onChange,
label = { Text(label) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
/**
* Proton's hosted CAPTCHA, which it puts in front of a sign-in it does not
* recognise — a new network, a VPN exit.
*
* In a WebView rather than the browser because the page reports success by
* posting a message to its host, not by redirecting: a browser has nowhere to
* post it back to. The page targets `window.parent`, which for a top-level
* page is the page itself, so a listener on its own window hears it.
*/
@SuppressLint("SetJavaScriptEnabled") // The verification page is a script; it does not run without.
@Composable
private fun VerificationDialog(url: String, onToken: (String) -> Unit, onDismiss: () -> Unit) {
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
Card(Modifier.fillMaxWidth().padding(16.dp).height(560.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"Confirm you are human",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f).padding(start = 16.dp),
)
IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, contentDescription = "Cancel") }
}
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
val main = Handler(Looper.getMainLooper())
var answered = false
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
addJavascriptInterface(
object {
@JavascriptInterface
fun post(message: String) {
// Every message the page posts lands here,
// most of them its own chatter.
val token = verificationToken(message) ?: return
main.post {
if (!answered) {
answered = true
onToken(token)
}
}
}
},
"pstrVerification",
)
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView, url: String?, favicon: android.graphics.Bitmap?) {
view.evaluateJavascript(VERIFICATION_BRIDGE, null)
}
override fun onPageFinished(view: WebView, url: String?) {
view.evaluateJavascript(VERIFICATION_BRIDGE, null)
}
// The bridge above is reachable by whatever the
// frame shows, so the frame stays on Proton.
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest) =
!isProtonPage(request.url.scheme, request.url.host)
}
loadUrl(url)
}
},
)
}
}
}
/** Installed once per page, at start and again at finish in case the first ran too early. */
private const val VERIFICATION_BRIDGE = """
if (!window.__pstrVerification) {
window.__pstrVerification = true;
window.addEventListener('message', function (event) {
try {
pstrVerification.post(typeof event.data === 'string' ? event.data : JSON.stringify(event.data));
} catch (e) {}
});
}
"""
/** Whether a page is Proton's own, over HTTPS. */
internal fun isProtonPage(scheme: String?, host: String?): Boolean =
scheme == "https" && host != null && (host == "proton.me" || host.endsWith(".proton.me"))
/**
* Walk the account's Drive and add a folder of it to the library.
*
* Full screen, because a Drive is deep and a dialog's height is a few rows of
* it. Listings are asked for here rather than through the model: they are
* this dialog's alone, and gone when it closes.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun DriveBrowserDialog(
actions: AccountActions,
onDismiss: () -> Unit,
) {
val trail = remember { mutableStateListOf<Pair<String, DrivePlaceRecord>>() }
var places by remember { mutableStateOf<List<DrivePlaceRecord>?>(null) }
var entries by remember { mutableStateOf<List<DriveEntryRecord>?>(null) }
var error by remember { mutableStateOf<String?>(null) }
val current = trail.lastOrNull()?.second
LaunchedEffect(current) {
error = null
if (current == null) {
if (places == null) {
runCatching { NativeRuntime.engine().drivePlaces() }
.onSuccess { places = it }
.onFailure { error = it.message }
}
} else {
entries = null
runCatching { NativeRuntime.engine().driveFolder(current.volumeId, current.linkId) }
.onSuccess { entries = it }
.onFailure { error = it.message }
}
}
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
BackHandler(enabled = trail.isNotEmpty()) { trail.removeAt(trail.lastIndex) }
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
trail.lastOrNull()?.first ?: "Your Drive",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
IconButton(onClick = { if (trail.isEmpty()) onDismiss() else trail.removeAt(trail.lastIndex) }) {
Icon(
if (trail.isEmpty()) Icons.Default.Close else Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = if (trail.isEmpty()) "Close" else "Up a folder",
)
}
},
)
},
bottomBar = {
// A folder already in the library is refused by the engine,
// with a message saying so.
val folder = current ?: return@Scaffold
Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.End) {
AccentButton(
onClick = {
actions.addFolder(trail.last().first, folder.volumeId, folder.linkId)
onDismiss()
},
) { Text("Add this folder") }
}
},
) { padding ->
Box(Modifier.fillMaxSize().padding(padding)) {
val loading = error == null && (if (current == null) places == null else entries == null)
when {
error != null -> EmptyState("Could not list this", error.orEmpty())
loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
current == null -> LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
items(places.orEmpty(), key = { "${it.volumeId}/${it.linkId}" }) { place ->
DriveRow(
icon = when (place.kind) {
PlaceType.MY_FILES -> Icons.Default.Storage
PlaceType.DEVICE -> Icons.Default.Computer
PlaceType.SHARED_WITH_ME -> Icons.Default.FolderShared
},
name = place.name,
detail = null,
onClick = { trail.add(place.name to place) },
)
}
}
entries.orEmpty().isEmpty() -> EmptyState("Empty folder", "Nothing to add from here.")
else -> LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
items(entries.orEmpty(), key = { "${it.volumeId}/${it.linkId}" }) { entry ->
DriveRow(
icon = when {
entry.isFolder -> Icons.Default.Folder
entry.isVideo -> Icons.Default.Movie
else -> Icons.Default.InsertDriveFile
},
name = entry.name,
detail = entry.size?.let(::formatBytes),
onClick = if (entry.isFolder) {
{
trail.add(
entry.name to DrivePlaceRecord(
PlaceType.MY_FILES,
entry.name,
entry.volumeId,
entry.linkId,
),
)
}
} else {
null
},
)
}
}
}
}
}
}
}
@Composable
private fun DriveRow(
icon: androidx.compose.ui.graphics.vector.ImageVector,
name: String,
detail: String?,
onClick: (() -> Unit)?,
) {
ListItem(
headlineContent = { Text(name, maxLines = 2, overflow = TextOverflow.Ellipsis) },
supportingContent = detail?.let { { Text(it) } },
leadingContent = {
Icon(
icon,
contentDescription = null,
tint = if (onClick != null) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier,
)
}
@@ -36,6 +36,10 @@ import uniffi.pstr_android.MetadataSettingsRecord
import uniffi.pstr_android.StorageUsageRecord import uniffi.pstr_android.StorageUsageRecord
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.WatchStateRecord import uniffi.pstr_android.WatchStateRecord
import uniffi.pstr_android.AccountState
import uniffi.pstr_android.SignInOutcome
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
data class AppUiState( data class AppUiState(
val loading: Boolean = true, val loading: Boolean = true,
@@ -62,6 +66,25 @@ data class AppUiState(
val message: String? = null, val message: String? = null,
/** What Undo on [message] puts back, when the message offers one. */ /** What Undo on [message] puts back, when the message offers one. */
val undo: List<WatchSnapshot>? = null, val undo: List<WatchSnapshot>? = null,
val account: AccountUiState = AccountUiState(),
)
/** The Proton account: where a sign-in stands, and how sync is doing. */
data class AccountUiState(
/** Null until the bridge has said. */
val state: AccountState? = null,
/** A sign-in step is out and not answered yet. */
val busy: Boolean = false,
/** Why the last sign-in step was refused. */
val error: String? = null,
/** The CAPTCHA page Proton wants solved before it accepts the sign-in. */
val verificationUrl: String? = null,
/** When watch history last synced, in epoch milliseconds. */
val syncedAt: Long? = null,
/** Positions the last sync took from other devices. */
val applied: Int = 0,
/** Why the last sync failed, until one succeeds. */
val syncError: String? = null,
) )
/** /**
@@ -84,6 +107,18 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
} }
} }
reload() reload()
viewModelScope.launch {
val account = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().accountState() } }
.getOrDefault(AccountState.SignedOut)
updateAccount { it.copy(state = account) }
// While the app is alive, on top of after sign-in and whenever the
// player closes. Cheap when nothing changed: one folder listing,
// and no upload.
while (isActive) {
syncWatchHistory()
delay(SYNC_INTERVAL_MS)
}
}
viewModelScope.launch { viewModelScope.launch {
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) } searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
} }
@@ -212,6 +247,148 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
fun dismissMessage() = mutableState.update { it.copy(message = null, undo = null) } fun dismissMessage() = mutableState.update { it.copy(message = null, undo = null) }
private fun updateAccount(change: (AccountUiState) -> AccountUiState) =
mutableState.update { it.copy(account = change(it.account)) }
/**
* Start signing in, or start again with [verificationToken] once the
* CAPTCHA from a first try is solved.
*/
fun signIn(username: String, password: String, verificationToken: String? = null) =
signInStep { engine -> engine.signIn(username, password, verificationToken) }
fun submitSecondFactor(code: String) = signInStep { engine -> engine.submitSecondFactor(code) }
fun submitMailboxPassword(password: String) =
signInStep(keepStepOnFailure = true) { engine -> engine.submitMailboxPassword(password) }
fun cancelSignIn() {
updateAccount { AccountUiState(state = AccountState.SignedOut) }
viewModelScope.launch { runCatching { NativeRuntime.engine().cancelSignIn() } }
}
/** The CAPTCHA page closed without a token. */
fun dismissVerification() = updateAccount { it.copy(verificationUrl = null, busy = false) }
private fun signInStep(
keepStepOnFailure: Boolean = false,
step: suspend (uniffi.pstr_android.AndroidEngine) -> SignInOutcome,
) {
updateAccount { it.copy(busy = true, error = null, verificationUrl = null) }
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { step(NativeRuntime.engine()) } }
.onSuccess { outcome ->
when (outcome) {
is SignInOutcome.Done -> {
updateAccount {
AccountUiState(state = AccountState.SignedIn(outcome.username))
}
mutableState.update { it.copy(message = "Signed in as ${outcome.username}") }
syncWatchHistory()
reload()
}
SignInOutcome.SecondFactor ->
updateAccount { it.copy(state = AccountState.SecondFactor, busy = false) }
SignInOutcome.MailboxPassword ->
updateAccount { it.copy(state = AccountState.MailboxPassword, busy = false) }
is SignInOutcome.HumanVerification ->
updateAccount { it.copy(verificationUrl = outcome.url) }
}
}
.onFailure { error ->
updateAccount {
it.copy(
busy = false,
error = error.message ?: "Sign-in failed",
// A refused code spends the half-made session.
state = if (keepStepOnFailure) it.state else AccountState.SignedOut,
)
}
}
}
}
fun signOut() {
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().signOut() } }
.onFailure(::reportError)
updateAccount { AccountUiState(state = AccountState.SignedOut) }
reload()
}
}
/** Sync now, from a tap: says so when it is done. */
fun syncNow() {
viewModelScope.launch {
if (syncWatchHistory()) {
val applied = mutableState.value.account.applied
mutableState.update {
it.copy(
message = if (applied > 0) {
"Watch history synced, $applied from other devices"
} else {
"Watch history is up to date"
},
)
}
}
}
}
/**
* Pull other devices' positions in and push this one's out. Quiet: it runs
* on a timer, and a failure is shown on the account card rather than as a
* snackbar every five minutes of an offline evening. Returns whether it
* synced.
*/
private suspend fun syncWatchHistory(): Boolean {
if (mutableState.value.account.state !is AccountState.SignedIn) return false
return runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().syncWatchHistory() } }
.fold(
onSuccess = { report ->
if (report == null) return false
updateAccount {
it.copy(
syncedAt = System.currentTimeMillis(),
applied = report.applied.toInt(),
syncError = null,
)
}
if (report.applied > 0u) reload()
true
},
onFailure = { error ->
val state = runCatching { NativeRuntime.engine().accountState() }.getOrNull()
updateAccount { it.copy(state = state ?: it.state, syncError = error.message) }
// Signed out by the engine: the session ended, and that is
// worth interrupting for, unlike a dropped connection.
if (state == AccountState.SignedOut) reportError(error)
false
},
)
}
/** After the player closes: where it stopped is what another device wants next. */
fun playerClosed() {
reload()
viewModelScope.launch { syncWatchHistory() }
}
fun addAccountFolder(name: String, volumeId: String, linkId: String) {
viewModelScope.launch {
mutableState.update { it.copy(refreshing = true) }
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
val share = engine.addAccountFolder(name, volumeId, linkId)
engine.crawl(share.id)
}
}.onFailure(::reportError)
mutableState.update { it.copy(refreshing = false) }
reload()
}
}
/** /**
* Every episode of a title watched or unwatched, with an Undo: a whole * Every episode of a title watched or unwatched, with an Undo: a whole
* title's positions are what this throws away, and a mis-tap on a tile's * title's positions are what this throws away, and a mis-tap on a tile's
@@ -501,6 +678,10 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } } }.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
} }
private companion object {
const val SYNC_INTERVAL_MS = 5 * 60 * 1000L
}
class Factory(private val context: Context, private val workManager: WorkManager) : ViewModelProvider.Factory { class Factory(private val context: Context, private val workManager: WorkManager) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = AppViewModel(context, workManager) as T override fun <T : ViewModel> create(modelClass: Class<T>): T = AppViewModel(context, workManager) as T
@@ -19,6 +19,7 @@ import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import io.narl.protonstream.download.RetainedDownload
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
/** /**
@@ -120,6 +121,20 @@ internal fun formatBytes(bytes: ULong): String {
} }
} }
/**
* One line under a download that has not finished: "Running · 120.0 MiB of
* 1.2 GiB · 2.4 MiB/s", or "Queued" while it waits for a slot.
*/
internal fun downloadStatusLine(download: RetainedDownload): String = listOfNotNull(
download.status.replaceFirstChar { it.uppercase() },
download.total.takeIf { it > 0L && download.downloaded > 0L }?.let {
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
},
download.bytesPerSecond
.takeIf { it > 0L && download.status == RetainedDownload.STATUS_RUNNING }
?.let { "${formatBytes(it.toULong())}/s" },
).joinToString(" · ")
/** /**
* What an episode row is headed with: "1. Mother and Children". * What an episode row is headed with: "1. Mother and Children".
* *
@@ -204,14 +204,7 @@ private fun PartialRow(
headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) }, headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) },
supportingContent = { supportingContent = {
Column { Column {
Text( Text(downloadStatusLine(download))
listOfNotNull(
download.status.replaceFirstChar { it.uppercase() },
download.total.takeIf { it > 0L }?.let {
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
},
).joinToString(" · "),
)
if (download.total > 0L) { if (download.total > 0L) {
AccentProgress( AccentProgress(
progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) }, progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) },
@@ -270,6 +270,18 @@ fun ProtonStreamApp(
shareLink, shareLink,
onShareLinkTaken, onShareLinkTaken,
body, body,
account = state.account,
accountActions = AccountActions(
signIn = model::signIn,
submitSecondFactor = model::submitSecondFactor,
submitMailboxPassword = model::submitMailboxPassword,
cancelSignIn = model::cancelSignIn,
dismissVerification = model::dismissVerification,
signOut = model::signOut,
syncNow = model::syncNow,
addFolder = model::addAccountFolder,
onError = model::reportError,
),
) )
Destination.History -> HistoryScreen( Destination.History -> HistoryScreen(
state, state,
@@ -308,7 +320,7 @@ fun ProtonStreamApp(
onClose = { onClose = {
playingTitleKey = null playingTitleKey = null
playerMinimized = false playerMinimized = false
model.reloadAfterMetadataChange() model.playerClosed()
}, },
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
@@ -338,7 +350,7 @@ fun ProtonStreamApp(
onClose = { onClose = {
playingTitleKey = null playingTitleKey = null
playerMinimized = false playerMinimized = false
model.reloadAfterMetadataChange() model.playerClosed()
}, },
) )
} }
@@ -32,6 +32,7 @@ import androidx.compose.ui.window.SecureFlagPolicy
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.narl.protonstream.ui.theme.AccentButton import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.TonalButton import io.narl.protonstream.ui.theme.TonalButton
import uniffi.pstr_android.AccountState
import uniffi.pstr_android.ShareRecord import uniffi.pstr_android.ShareRecord
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
@@ -56,16 +57,40 @@ internal fun SharesScreen(
incomingLink: String?, incomingLink: String?,
onIncomingLinkTaken: () -> Unit, onIncomingLinkTaken: () -> Unit,
padding: PaddingValues, padding: PaddingValues,
account: AccountUiState = AccountUiState(),
accountActions: AccountActions? = null,
) { ) {
var showAdd by remember { mutableStateOf(false) } var showAdd by remember { mutableStateOf(false) }
var signingIn by remember { mutableStateOf(false) }
var browsing by remember { mutableStateOf(false) }
var repairing by remember { mutableStateOf<ShareRecord?>(null) } var repairing by remember { mutableStateOf<ShareRecord?>(null) }
var removing by remember { mutableStateOf<ShareRecord?>(null) } var removing by remember { mutableStateOf<ShareRecord?>(null) }
TabPage("Shares", padding) { body -> TabPage("Shares", padding) { body ->
Box(Modifier.fillMaxSize().padding(body)) { Box(Modifier.fillMaxSize().padding(body)) {
if (shares.isEmpty()) { if (shares.isEmpty() && account.state == null) {
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.") EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
} else { } else {
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) { LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
if (accountActions != null) {
item(key = "account") {
AccountCard(
account,
accountActions,
onSignIn = { signingIn = true },
onBrowse = { browsing = true },
)
}
}
if (shares.isEmpty()) {
item(key = "empty") {
Text(
"No shares yet. Add a public link, or a folder of your Drive once signed in.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
}
items(shares, key = { it.id }) { share -> items(shares, key = { it.id }) { share ->
ShareRow( ShareRow(
share = share, share = share,
@@ -95,6 +120,14 @@ internal fun SharesScreen(
onAdd = onAdd, onAdd = onAdd,
) )
} }
if (accountActions != null && (signingIn || account.state == AccountState.SecondFactor ||
account.state == AccountState.MailboxPassword)
) {
SignInDialog(account, accountActions, onDismiss = { signingIn = false })
}
if (accountActions != null && browsing) {
DriveBrowserDialog(accountActions, onDismiss = { browsing = false })
}
repairing?.let { share -> repairing?.let { share ->
RepairShareDialog( RepairShareDialog(
share = share, share = share,
@@ -139,7 +172,13 @@ private fun ShareRow(
ListItem( ListItem(
headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) }, headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) },
supportingContent = { supportingContent = {
Text(if (share.hasCustomPassword) "Link and custom password, stored securely" else "Public link") Text(
when {
share.fromAccount -> "Folder of your Drive"
share.hasCustomPassword -> "Link and custom password, stored securely"
else -> "Public link"
},
)
}, },
leadingContent = { leadingContent = {
Icon( Icon(
@@ -164,11 +203,13 @@ private fun ShareRow(
// Not a remove: a share whose secret has become unreadable // Not a remove: a share whose secret has become unreadable
// cannot be removed either, since removal deletes a secret // cannot be removed either, since removal deletes a secret
// the store can no longer touch. // the store can no longer touch.
if (!share.fromAccount) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Re-enter link") }, text = { Text("Re-enter link") },
leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) }, leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) },
onClick = { menu = false; onRepair() }, onClick = { menu = false; onRepair() },
) )
}
DropdownMenuItem( DropdownMenuItem(
text = { Text("Remove") }, text = { Text("Remove") },
leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) }, leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) },
@@ -636,8 +636,7 @@ private fun EpisodeRow(
fill = solid(MaterialTheme.colorScheme.tertiary), fill = solid(MaterialTheme.colorScheme.tertiary),
) )
Text( Text(
"${active.status.replaceFirstChar { it.uppercase() }} · " + downloadStatusLine(active),
"${formatBytes(active.downloaded.toULong())} of ${formatBytes(active.total.toULong())}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -654,6 +653,8 @@ internal fun ChangeMatchDialog(
) { ) {
var term by remember(title.key) { mutableStateOf(title.canonicalName ?: title.name) } var term by remember(title.key) { mutableStateOf(title.canonicalName ?: title.name) }
var searching by remember { mutableStateOf(false) } var searching by remember { mutableStateOf(false) }
// Choosing fetches the entry's episodes too, which is a few requests.
var choosing by remember { mutableStateOf(false) }
var options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) } var options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
AlertDialog( AlertDialog(
@@ -672,16 +673,26 @@ internal fun ChangeMatchDialog(
searching = false searching = false
} }
}) { Text(if (searching) "Searching…" else "Search") } }) { Text(if (searching) "Searching…" else "Search") }
if (choosing) {
Text(
"Fetching its episodes…",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 8.dp),
)
}
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 300.dp)) { LazyColumn(Modifier.fillMaxWidth().heightIn(max = 300.dp)) {
items(options, key = { "${it.provider}:${it.remoteId}" }) { option -> items(options, key = { "${it.provider}:${it.remoteId}" }) { option ->
EdgedButton( EdgedButton(
onClick = { onClick = {
choosing = true
scope.launch { scope.launch {
runCatching { runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().chooseMatch(title.key, option) } withContext(Dispatchers.IO) { NativeRuntime.engine().chooseMatch(title.key, option) }
}.onSuccess { onChanged() }.onFailure(onError) }.onSuccess { onChanged() }.onFailure(onError)
choosing = false
} }
}, },
enabled = !choosing,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp), modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
) { ) {
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
@@ -0,0 +1,37 @@
package io.narl.protonstream.download
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class TransferRateTest {
@Test
fun `the first sample has no rate to report`() {
assertEquals(0L, TransferRate().sample(4L * 1024 * 1024, 1_000))
}
@Test
fun `a steady transfer reports its rate`() {
val rate = TransferRate()
rate.sample(0, 0)
assertEquals(1_000_000L, rate.sample(1_000_000, 1_000))
assertEquals(1_000_000L, rate.sample(2_000_000, 2_000))
}
@Test
fun `one slow block moves the rate without halving it`() {
val rate = TransferRate()
rate.sample(0, 0)
rate.sample(1_000_000, 1_000)
val after = rate.sample(2_000_000, 3_000)
assertTrue(after in 600_000L..999_999L)
}
@Test
fun `a resumed download starting below the last sample starts over`() {
val rate = TransferRate()
rate.sample(0, 0)
rate.sample(1_000_000, 1_000)
assertEquals(1_000_000L, rate.sample(0, 2_000))
}
}
@@ -0,0 +1,38 @@
package io.narl.protonstream.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AccountUiTest {
@Test
fun `the sync line says how long ago and what came in`() {
val minute = 60_000L
assertEquals("Watch history syncs with your Drive", syncLine(AccountUiState(), 0))
assertEquals("Synced just now", syncLine(AccountUiState(syncedAt = 0), 30_000))
assertEquals("Synced 5 min ago", syncLine(AccountUiState(syncedAt = 0), 5 * minute))
assertEquals(
"Synced 2 h ago · 3 from other devices",
syncLine(AccountUiState(syncedAt = 0, applied = 3), 120 * minute),
)
}
@Test
fun `a failed sync is said in place of when it last worked`() {
assertEquals(
"Watch history did not sync: offline",
syncLine(AccountUiState(syncedAt = 0, syncError = "offline"), 0),
)
}
@Test
fun `the verification page may only navigate within Proton over https`() {
assertTrue(isProtonPage("https", "verify.proton.me"))
assertTrue(isProtonPage("https", "proton.me"))
assertFalse(isProtonPage("http", "verify.proton.me"))
assertFalse(isProtonPage("https", "proton.me.example.com"))
assertFalse(isProtonPage("https", "notproton.me"))
assertFalse(isProtonPage("https", null))
}
}
+1
View File
@@ -24,6 +24,7 @@ pstr-core.workspace = true
pstr-meta.workspace = true pstr-meta.workspace = true
pstr-stream.workspace = true pstr-stream.workspace = true
tokio.workspace = true tokio.workspace = true
futures.workspace = true
parking_lot.workspace = true parking_lot.workspace = true
thiserror.workspace = true thiserror.workspace = true
uniffi.workspace = true uniffi.workspace = true
+481 -40
View File
@@ -10,6 +10,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, OnceLock}; use std::sync::{Arc, OnceLock};
use parking_lot::Mutex; use parking_lot::Mutex;
use pstr_core::account::{DriveEntry, PendingSignIn, PlaceKind, SignIn};
use pstr_core::appearance::{Accent, Appearance, Flavor, Palette}; use pstr_core::appearance::{Accent, Appearance, Flavor, Palette};
use pstr_core::browse::Sort; use pstr_core::browse::Sort;
use pstr_core::catalog::{Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows}; use pstr_core::catalog::{Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows};
@@ -20,12 +21,17 @@ use pstr_core::metadata::{
EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata, TitleNames, EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata, TitleNames,
}; };
use pstr_core::prefs::PlaybackPrefs; use pstr_core::prefs::PlaybackPrefs;
use pstr_core::proton_drive_rs::{ProtonDrivePublicLinkClient, ThumbnailType}; use pstr_core::proton_drive_rs::ThumbnailType;
use pstr_core::proton_sdk::api::HumanVerificationCredential;
use pstr_core::proton_sdk::ids::{LinkId, VolumeId}; use pstr_core::proton_sdk::ids::{LinkId, VolumeId};
use pstr_core::{SecretStore, ShareStore, SharedLibrary}; use pstr_core::shares::AccountFolder;
use pstr_core::sync::WatchSync;
use pstr_core::{
Account, AccountStore, SecretStore, Share, ShareClient, ShareStore, SharedLibrary,
};
use pstr_stream::{ use pstr_stream::{
BlockSource, DiskCacheConfig, FileBlocks, LibraryOpener, NodeUid, StreamConfig, StreamSource, BlockSource, COPY_BLOCKS_IN_FLIGHT, DiskCacheConfig, FileBlocks, LibraryOpener, NodeUid,
VideoStream, StreamConfig, StreamSource, VideoStream,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -131,6 +137,102 @@ pub struct ShareRecord {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub has_custom_password: bool, pub has_custom_password: bool,
/// A folder of the signed-in account rather than a public link.
#[uniffi(default = false)]
pub from_account: bool,
}
fn share_record(share: Share) -> ShareRecord {
ShareRecord {
from_account: share.folder.is_some(),
id: share.id,
name: share.name,
has_custom_password: share.has_custom_password,
}
}
/// Where the Proton account stands.
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
pub enum AccountState {
SignedOut,
SignedIn {
username: String,
},
/// A sign-in is waiting for a second-factor code.
SecondFactor,
/// A sign-in is waiting for the mailbox password.
MailboxPassword,
}
/// What a sign-in step led to.
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
pub enum SignInOutcome {
Done {
username: String,
},
SecondFactor,
MailboxPassword,
/// Proton wants a CAPTCHA first. Show `url` in a WebView, read the token
/// out of its messages with [`verification_token`], and sign in again with
/// it.
HumanVerification {
url: String,
},
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct SyncRecord {
/// Episodes whose position another device had newer.
pub applied: u32,
pub uploaded: bool,
/// Other installations whose history was read.
pub devices: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum PlaceType {
MyFiles,
Device,
SharedWithMe,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct DrivePlaceRecord {
pub kind: PlaceType,
pub name: String,
pub volume_id: String,
pub link_id: String,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct DriveEntryRecord {
pub name: String,
pub volume_id: String,
pub link_id: String,
pub is_folder: bool,
pub size: Option<u64>,
pub is_video: bool,
}
fn drive_entry_record(entry: DriveEntry) -> DriveEntryRecord {
DriveEntryRecord {
is_video: entry
.media_type
.as_deref()
.is_some_and(|media| media.starts_with("video/")),
name: entry.name,
volume_id: entry.uid.volume_id.as_str().to_owned(),
link_id: entry.uid.link_id.as_str().to_owned(),
is_folder: entry.is_folder,
size: entry.size.and_then(|size| u64::try_from(size).ok()),
}
}
/// The CAPTCHA token in a message the verification page posted, or `None` for
/// one of its other messages. See [`SignInOutcome::HumanVerification`].
#[uniffi::export]
pub fn verification_token(message: String) -> Option<String> {
pstr_core::account::verification_token(&message)
} }
#[derive(Debug, Clone, uniffi::Enum)] #[derive(Debug, Clone, uniffi::Enum)]
@@ -811,7 +913,7 @@ pub struct AndroidEngine {
/// Held across the open handshakes so concurrent callers share one build. /// Held across the open handshakes so concurrent callers share one build.
connecting: tokio::sync::Mutex<()>, connecting: tokio::sync::Mutex<()>,
/// Clients carried over from a retired connection, for the next build. /// Clients carried over from a retired connection, for the next build.
reusable_clients: Mutex<Option<BTreeMap<String, ProtonDrivePublicLinkClient>>>, reusable_clients: Mutex<Option<BTreeMap<String, ShareClient>>>,
/// The whole library as bridge records, behind the catalog's write counter. /// The whole library as bridge records, behind the catalog's write counter.
library_cache: Mutex<Option<CachedLibrary>>, library_cache: Mutex<Option<CachedLibrary>>,
/// Serializes offline publication with share removal cleanup. /// Serializes offline publication with share removal cleanup.
@@ -830,8 +932,30 @@ pub struct AndroidEngine {
/// The streaming cache's budget in bytes, for the next connection and, /// The streaming cache's budget in bytes, for the next connection and,
/// through [`AndroidEngine::set_stream_cache_budget`], the open one. /// through [`AndroidEngine::set_stream_cache_budget`], the open one.
cache_budget: AtomicU64, cache_budget: AtomicU64,
accounts: AccountStore,
/// The signed-in Proton account, if any.
account: Mutex<Option<Account>>,
/// A sign-in waiting for a code or the mailbox password.
pending_sign_in: Mutex<Option<PendingSignIn>>,
/// `None` when this installation's sync identity could not be written.
watch_sync: Option<Arc<WatchSync>>,
/// How many offline downloads may transfer at once.
///
/// WorkManager starts every queued worker whose constraints are met, so
/// "Download season" on twelve episodes was twelve transfers splitting one
/// link: each crawled, and none reported progress for long enough to look
/// stuck. The rest wait here, reported as queued, and start as one ends.
download_permits: Arc<tokio::sync::Semaphore>,
} }
/// How many offline downloads transfer at once. See
/// [`AndroidEngine::download_permits`]; the desktop runs three, a phone link
/// is better served by two.
const OFFLINE_DOWNLOAD_CONCURRENCY: usize = 2;
/// How often a download waiting for a slot asks whether it was cancelled.
const DOWNLOAD_WAIT_POLL: std::time::Duration = std::time::Duration::from_millis(500);
/// How long a visitor session is assumed good for without a refresh. /// How long a visitor session is assumed good for without a refresh.
/// ///
/// A refresh costs one round trip. Not refreshing costs an authentication /// A refresh costs one round trip. Not refreshing costs an authentication
@@ -854,6 +978,17 @@ impl AndroidEngine {
Arc::clone(&secret_store), Arc::clone(&secret_store),
)); ));
let catalog = Catalog::open(&dirs.catalog_db()).map_err(BridgeError::from_display)?; let catalog = Catalog::open(&dirs.catalog_db()).map_err(BridgeError::from_display)?;
let accounts = AccountStore::new(Arc::clone(&secret_store));
// An unreadable session is a signed-out app, not one that will not
// start: the library is the point, and the account is an extra.
let account = accounts.resume().unwrap_or_else(|error| {
log::warn!("resume the Proton session: {error}");
None
});
let watch_sync = WatchSync::open(&dirs)
.inspect_err(|error| log::warn!("watch-history sync unavailable: {error}"))
.ok()
.map(Arc::new);
let runtime = tokio::runtime::Builder::new_multi_thread() let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all() .enable_all()
.build() .build()
@@ -875,23 +1010,224 @@ impl AndroidEngine {
thumbnails: tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY), thumbnails: tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY),
session_refreshed: Mutex::new(HashMap::new()), session_refreshed: Mutex::new(HashMap::new()),
cache_budget: AtomicU64::new(DiskCacheConfig::DEFAULT_BUDGET_BYTES), cache_budget: AtomicU64::new(DiskCacheConfig::DEFAULT_BUDGET_BYTES),
download_permits: Arc::new(tokio::sync::Semaphore::new(OFFLINE_DOWNLOAD_CONCURRENCY)),
accounts,
account: Mutex::new(account),
pending_sign_in: Mutex::new(None),
watch_sync,
})) }))
} }
pub fn account_state(&self) -> AccountState {
if let Some(account) = self.account.lock().as_ref() {
return AccountState::SignedIn {
username: account.username().to_owned(),
};
}
// A pending sign-in is only ever reached through `sign_in`, whose
// outcome already said which of the two it is waiting for; the
// distinction is not kept, and a screen rebuilt mid-sign-in starts it
// over.
AccountState::SignedOut
}
/// Start signing in. `verification_token` is the CAPTCHA answer, after a
/// first attempt came back [`SignInOutcome::HumanVerification`].
pub async fn sign_in(
self: Arc<Self>,
username: String,
password: String,
verification_token: Option<String>,
) -> Result<SignInOutcome, BridgeError> {
let runtime = Arc::clone(&self.runtime);
spawned(&runtime, async move {
let verification = verification_token.map(HumanVerificationCredential::captcha);
match self
.accounts
.sign_in(&username, &password, verification)
.await
{
Ok(step) => Ok(self.sign_in_step(step)),
Err(pstr_core::Error::HumanVerification(challenge)) => {
Ok(SignInOutcome::HumanVerification {
url: challenge.verification_url(),
})
}
Err(error) => Err(BridgeError::from_display(error)),
}
})
.await
.map_err(BridgeError::from_display)?
}
/// A wrong code ends the attempt: the half-made session is spent, and the
/// viewer signs in again from the start.
pub async fn submit_second_factor(
self: Arc<Self>,
code: String,
) -> Result<SignInOutcome, BridgeError> {
let runtime = Arc::clone(&self.runtime);
spawned(&runtime, async move {
let pending =
self.pending_sign_in
.lock()
.take()
.ok_or_else(|| BridgeError::Failure {
reason: "that sign-in expired; start again".to_owned(),
})?;
let step = pending
.second_factor(&code)
.await
.map_err(BridgeError::from_display)?;
Ok(self.sign_in_step(step))
})
.await
.map_err(BridgeError::from_display)?
}
/// A wrong mailbox password keeps the sign-in, so it can be typed again.
pub async fn submit_mailbox_password(
self: Arc<Self>,
password: String,
) -> Result<SignInOutcome, BridgeError> {
let runtime = Arc::clone(&self.runtime);
spawned(&runtime, async move {
let pending =
self.pending_sign_in
.lock()
.take()
.ok_or_else(|| BridgeError::Failure {
reason: "that sign-in expired; start again".to_owned(),
})?;
match pending.mailbox_password(&password).await {
Ok(account) => Ok(self.sign_in_step(SignIn::Done(account))),
Err(error) => {
*self.pending_sign_in.lock() = Some(pending);
Err(BridgeError::from_display(error))
}
}
})
.await
.map_err(BridgeError::from_display)?
}
pub fn cancel_sign_in(&self) {
self.pending_sign_in.lock().take();
}
/// Sign out and forget the stored session. The library keeps everything;
/// the account's folders stop opening until the next sign-in.
pub async fn sign_out(self: Arc<Self>) -> Result<(), BridgeError> {
let runtime = Arc::clone(&self.runtime);
spawned(&runtime, async move {
let account = self.account.lock().take();
let result = self.accounts.sign_out(account.as_ref()).await;
self.signed_out();
result.map_err(BridgeError::from_display)
})
.await
.map_err(BridgeError::from_display)?
}
/// Sync watch history with the account's Drive. `None` when nobody is
/// signed in.
///
/// A session that has ended — revoked elsewhere, or idle too long — signs
/// the app out and fails with why, rather than failing the same way on
/// every later call.
pub async fn sync_watch_history(self: Arc<Self>) -> Result<Option<SyncRecord>, BridgeError> {
let runtime = Arc::clone(&self.runtime);
spawned(&runtime, async move {
let (Some(account), Some(sync)) =
(self.account.lock().clone(), self.watch_sync.clone())
else {
return Ok(None);
};
match sync.sync(&account, &self.catalog).await {
Ok(report) => Ok(Some(SyncRecord {
applied: u32::try_from(report.applied).unwrap_or(u32::MAX),
uploaded: report.uploaded,
devices: u32::try_from(report.devices).unwrap_or(u32::MAX),
})),
Err(error) if error.is_signed_out() => {
let _ = self.accounts.sign_out(None).await;
self.signed_out();
Err(BridgeError::Failure {
reason: "Your Proton session ended. Sign in again to keep syncing."
.to_owned(),
})
}
Err(error) => Err(BridgeError::from_display(error)),
}
})
.await
.map_err(BridgeError::from_display)?
}
/// The account's top-level places: My Files, devices, shared with me.
pub async fn drive_places(self: Arc<Self>) -> Result<Vec<DrivePlaceRecord>, BridgeError> {
let runtime = Arc::clone(&self.runtime);
spawned(&runtime, async move {
let account = self.signed_in_account()?;
let places = account.places().await.map_err(BridgeError::from_display)?;
Ok(places
.into_iter()
.map(|place| DrivePlaceRecord {
kind: match place.kind {
PlaceKind::MyFiles => PlaceType::MyFiles,
PlaceKind::Device => PlaceType::Device,
PlaceKind::SharedWithMe => PlaceType::SharedWithMe,
},
name: place.name,
volume_id: place.uid.volume_id.as_str().to_owned(),
link_id: place.uid.link_id.as_str().to_owned(),
})
.collect())
})
.await
.map_err(BridgeError::from_display)?
}
/// One folder of the account: folders first, then files.
pub async fn drive_folder(
self: Arc<Self>,
volume_id: String,
link_id: String,
) -> Result<Vec<DriveEntryRecord>, BridgeError> {
let runtime = Arc::clone(&self.runtime);
spawned(&runtime, async move {
let account = self.signed_in_account()?;
let entries = account
.folder_children(&node_uid(&volume_id, &link_id))
.await
.map_err(BridgeError::from_display)?;
Ok(entries.into_iter().map(drive_entry_record).collect())
})
.await
.map_err(BridgeError::from_display)?
}
/// Add a folder of the account as a share. Crawl it next, as after
/// [`Self::add_share`].
pub fn add_account_folder(
&self,
name: String,
volume_id: String,
link_id: String,
) -> Result<ShareRecord, BridgeError> {
let share = self
.store
.add_folder(&name, AccountFolder { volume_id, link_id })
.map_err(BridgeError::from_display)?;
self.invalidate_connection(&share.id);
Ok(share_record(share))
}
pub fn shares(&self) -> Result<Vec<ShareRecord>, BridgeError> { pub fn shares(&self) -> Result<Vec<ShareRecord>, BridgeError> {
self.store self.store
.list() .list()
.map_err(BridgeError::from_display) .map_err(BridgeError::from_display)
.map(|shares| { .map(|shares| shares.into_iter().map(share_record).collect())
shares
.into_iter()
.map(|share| ShareRecord {
id: share.id,
name: share.name,
has_custom_password: share.has_custom_password,
})
.collect()
})
} }
pub fn add_share( pub fn add_share(
@@ -905,11 +1241,7 @@ impl AndroidEngine {
.add(&name, &url, custom_password.as_deref()) .add(&name, &url, custom_password.as_deref())
.map_err(BridgeError::from_display)?; .map_err(BridgeError::from_display)?;
self.invalidate_connection(&share.id); self.invalidate_connection(&share.id);
Ok(ShareRecord { Ok(share_record(share))
id: share.id,
name: share.name,
has_custom_password: share.has_custom_password,
})
} }
/// Re-supply the link behind a share whose stored secret is unreadable. /// Re-supply the link behind a share whose stored secret is unreadable.
@@ -928,11 +1260,7 @@ impl AndroidEngine {
.replace_secrets(&share_id, &url, custom_password.as_deref()) .replace_secrets(&share_id, &url, custom_password.as_deref())
.map_err(BridgeError::from_display)?; .map_err(BridgeError::from_display)?;
self.invalidate_connection(&share.id); self.invalidate_connection(&share.id);
Ok(ShareRecord { Ok(share_record(share))
id: share.id,
name: share.name,
has_custom_password: share.has_custom_password,
})
} }
pub fn remove_share(&self, share_id: String) -> Result<(), BridgeError> { pub fn remove_share(&self, share_id: String) -> Result<(), BridgeError> {
@@ -1163,7 +1491,17 @@ impl AndroidEngine {
.map_err(BridgeError::from_display)? .map_err(BridgeError::from_display)?
} }
pub fn choose_match(&self, title_key: String, found: MatchRecord) -> Result<(), BridgeError> { /// Pin a title to the entry the viewer picked, then fetch that entry's
/// episodes, backdrop and franchise chain — the same enrichment a match run
/// does, so the title page shows the new match's episodes as soon as this
/// returns rather than after the next run.
pub async fn choose_match(
self: Arc<Self>,
title_key: String,
found: MatchRecord,
) -> Result<(), BridgeError> {
let runtime = Arc::clone(&self.runtime);
spawned(&runtime, async move {
let service = self.metadata_service()?; let service = self.metadata_service()?;
let metadata = title_metadata(found); let metadata = title_metadata(found);
if metadata.provider != service.provider() { if metadata.provider != service.provider() {
@@ -1171,11 +1509,29 @@ impl AndroidEngine {
reason: "match came from a different metadata provider".to_owned(), reason: "match came from a different metadata provider".to_owned(),
}); });
} }
self.title(&title_key)?; let title = self.title(&title_key)?;
let outcome = service.choose(&title, metadata).await;
if let Some(record) = &outcome.record {
self.catalog self.catalog
.lock() .lock()
.set_metadata(&service.chosen(title_key, metadata)) .set_metadata(record)
.map_err(BridgeError::from_display) .map_err(BridgeError::from_display)?;
}
if let Some(enrichment) = &outcome.enrichment {
let stored = self.catalog.lock().set_enrichment(
&title_key,
service.provider(),
now(),
enrichment,
);
if let Err(error) = stored {
log::warn!("store enrichment for {title_key}: {error}");
}
}
Ok(())
})
.await
.map_err(BridgeError::from_display)?
} }
pub fn forget_match(&self, title_key: String) -> Result<(), BridgeError> { pub fn forget_match(&self, title_key: String) -> Result<(), BridgeError> {
@@ -1754,6 +2110,46 @@ impl AndroidEngine {
}) })
} }
fn sign_in_step(&self, step: SignIn) -> SignInOutcome {
match step {
SignIn::Done(account) => {
let username = account.username().to_owned();
*self.account.lock() = Some(account);
if let Some(sync) = &self.watch_sync {
sync.reset();
}
// Folders of the account that failed to open are openable now.
self.invalidate_connection("");
SignInOutcome::Done { username }
}
SignIn::SecondFactor(pending) => {
*self.pending_sign_in.lock() = Some(pending);
SignInOutcome::SecondFactor
}
SignIn::MailboxPassword(pending) => {
*self.pending_sign_in.lock() = Some(pending);
SignInOutcome::MailboxPassword
}
}
}
fn signed_out(&self) {
self.account.lock().take();
if let Some(sync) = &self.watch_sync {
sync.reset();
}
self.invalidate_connection("");
}
fn signed_in_account(&self) -> Result<Account, BridgeError> {
self.account
.lock()
.clone()
.ok_or_else(|| BridgeError::Failure {
reason: "not signed in to Proton".to_owned(),
})
}
/// Retire the cached connection after a mutation of `share_id`. /// Retire the cached connection after a mutation of `share_id`.
/// ///
/// The cached `Connection` is dropped here rather than left to be noticed /// The cached `Connection` is dropped here rather than left to be noticed
@@ -1867,7 +2263,9 @@ impl AndroidEngine {
let generation = self.share_generation.load(Ordering::Acquire); let generation = self.share_generation.load(Ordering::Acquire);
let reusable = self.reusable_clients.lock().take().unwrap_or_default(); let reusable = self.reusable_clients.lock().take().unwrap_or_default();
let (library, failures) = SharedLibrary::open_all_reusing(&self.store, reusable) let account = self.account.lock().clone();
let (library, failures) =
SharedLibrary::open_all_reusing(&self.store, reusable, account.as_ref())
.await .await
.map_err(BridgeError::from_display)?; .map_err(BridgeError::from_display)?;
let open_failures: Vec<String> = failures let open_failures: Vec<String> = failures
@@ -1923,6 +2321,27 @@ impl AndroidEngine {
share_id: &str, share_id: &str,
uid: &NodeUid, uid: &NodeUid,
) -> Result<VideoStream, BridgeError> { ) -> Result<VideoStream, BridgeError> {
self.open_reauthenticating(connection, share_id, uid, false)
.await
}
/// [`Self::open_from_source`], or with `copy` the stream an offline
/// download reads: no read-ahead, nothing added to the memory ring or the
/// disk cache. See [`StreamSource::open_for_copy`].
async fn open_reauthenticating(
&self,
connection: &Connection,
share_id: &str,
uid: &NodeUid,
copy: bool,
) -> Result<VideoStream, BridgeError> {
let open = || async {
if copy {
connection.source.open_for_copy(share_id, uid).await
} else {
connection.source.open(share_id, uid).await
}
};
let stale = self let stale = self
.session_refreshed .session_refreshed
.lock() .lock()
@@ -1931,15 +2350,11 @@ impl AndroidEngine {
if stale { if stale {
self.refresh_session(connection, share_id).await; self.refresh_session(connection, share_id).await;
} }
match connection.source.open(share_id, uid).await { match open().await {
Ok(stream) => Ok(stream), Ok(stream) => Ok(stream),
Err(_) if !stale => { Err(_) if !stale => {
self.refresh_session(connection, share_id).await; self.refresh_session(connection, share_id).await;
connection open().await.map_err(BridgeError::from_display)
.source
.open(share_id, uid)
.await
.map_err(BridgeError::from_display)
} }
Err(error) => Err(BridgeError::from_display(error)), Err(error) => Err(BridgeError::from_display(error)),
} }
@@ -2071,9 +2486,26 @@ impl AndroidEngine {
return Ok(record); return Ok(record);
} }
// Nothing is reported until a slot is held, so the worker's record
// stays queued while it waits rather than claiming to be running.
let _permit = loop {
if cancelled(observer.as_ref()) {
return Err(BridgeError::Failure {
reason: "offline download cancelled".to_owned(),
});
}
let permits = Arc::clone(&self.download_permits);
tokio::select! {
permit = permits.acquire_owned() => {
break permit.map_err(BridgeError::from_display)?;
}
() = tokio::time::sleep(DOWNLOAD_WAIT_POLL) => {}
}
};
let connection = self.connection().await?; let connection = self.connection().await?;
let stream = self let stream = self
.open_from_source(&connection, share_id, &node_uid(volume_id, link_id)) .open_reauthenticating(&connection, share_id, &node_uid(volume_id, link_id), true)
.await?; .await?;
let revision_id = stream.revision_id().to_owned(); let revision_id = stream.revision_id().to_owned();
let block_sizes = stream.block_sizes().to_vec(); let block_sizes = stream.block_sizes().to_vec();
@@ -2125,7 +2557,13 @@ impl AndroidEngine {
let mut output = prepare_partial_file(&temporary, offset).await?; let mut output = prepare_partial_file(&temporary, offset).await?;
report(observer.as_ref(), offset, total); report(observer.as_ref(), offset, total);
use futures::TryStreamExt as _;
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
// A few blocks in flight, written in order. One at a time the copy ran
// at one round trip per 4 MiB; through the playback stream it carried
// twelve blocks of read-ahead, so the first block — and with it the
// first progress report — waited on all twelve sharing the link.
let mut fetches = stream.blocks_in_order(block_index, COPY_BLOCKS_IN_FLIGHT);
while block_index < block_sizes.len() { while block_index < block_sizes.len() {
if cancelled(observer.as_ref()) { if cancelled(observer.as_ref()) {
output.sync_all().await.map_err(BridgeError::from_display)?; output.sync_all().await.map_err(BridgeError::from_display)?;
@@ -2134,10 +2572,13 @@ impl AndroidEngine {
}); });
} }
let size = block_sizes[block_index]; let size = block_sizes[block_index];
let bytes = stream let bytes = fetches
.read_range(offset, size) .try_next()
.await .await
.map_err(BridgeError::from_display)?; .map_err(BridgeError::from_display)?
.ok_or_else(|| BridgeError::Failure {
reason: format!("offline block {block_index} never arrived"),
})?;
if bytes.len() as u64 != size { if bytes.len() as u64 != size {
return Err(BridgeError::Failure { return Err(BridgeError::Failure {
reason: format!( reason: format!(
+62 -2
View File
@@ -15,7 +15,8 @@ use pstr_core::library::Library;
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, TitleNames}; use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, TitleNames};
use crate::engine::{ use crate::engine::{
DownloadItem, DownloadKey, Engine, Event, ImageCache, describe_failures, watch_state, AccountStatus, DownloadItem, DownloadKey, Engine, Event, ImageCache, describe_failures,
watch_state,
}; };
use crate::pacing::Pacer; use crate::pacing::Pacer;
use crate::playback::{Playback, PlaybackTarget}; use crate::playback::{Playback, PlaybackTarget};
@@ -126,6 +127,25 @@ pub enum Action {
url: String, url: String,
password: Option<String>, password: Option<String>,
}, },
/// Add a folder of the signed-in account as a share.
AddAccountFolder {
name: String,
uid: pstr_core::proton_sdk::ids::NodeUid,
},
SignIn {
username: String,
password: String,
},
SubmitSecondFactor(String),
SubmitMailboxPassword(String),
CancelSignIn,
SignOut,
/// Sync watch history with the account's Drive now.
SyncNow,
/// List the account's top-level places in the Drive browser.
BrowsePlaces,
/// List one folder in the Drive browser.
BrowseFolder(pstr_core::proton_sdk::ids::NodeUid),
/// Ask whether to forget a share. [`Action::ForgetShare`] does it. /// Ask whether to forget a share. [`Action::ForgetShare`] does it.
RemoveShare(String), RemoveShare(String),
ForgetShare(String), ForgetShare(String),
@@ -535,6 +555,8 @@ pub struct App {
/// library means "not loaded", not "nothing in it". /// library means "not loaded", not "nothing in it".
pub loaded: bool, pub loaded: bool,
pub form: ShareForm, pub form: ShareForm,
/// The Proton account section of the shares page.
pub account: ui::account::AccountPanel,
pub playback: Option<Playback>, pub playback: Option<Playback>,
/// Whether the player page's controls are showing, and why. /// Whether the player page's controls are showing, and why.
pub overlay: ui::player::Overlay, pub overlay: ui::player::Overlay,
@@ -630,6 +652,7 @@ impl App {
view: LibraryView::default(), view: LibraryView::default(),
loaded: false, loaded: false,
form: ShareForm::default(), form: ShareForm::default(),
account: ui::account::AccountPanel::default(),
playback: None, playback: None,
overlay: ui::player::Overlay::default(), overlay: ui::player::Overlay::default(),
up_next: UpNext::default(), up_next: UpNext::default(),
@@ -882,6 +905,9 @@ impl App {
None => Page::Library, None => Page::Library,
}; };
} }
// Where it stopped is the position another device
// wants next, so it goes now rather than on the timer.
self.engine.sync_watch_history();
} }
} }
Event::Watched { Event::Watched {
@@ -905,6 +931,23 @@ impl App {
} }
} }
Event::OfflineFiles(files) => self.offline_files = files, Event::OfflineFiles(files) => self.offline_files = files,
Event::Account(status) => {
if let AccountStatus::SignedIn(username) = &status
&& self.account.busy
{
self.note(ctx, format!("signed in as {username}"), false);
}
self.account.set_status(status);
}
Event::SignInFailed(error) => self.account.refused(error),
Event::Synced(report) => {
self.account.sync_error = None;
self.account.synced = Some((ui::account::unix_now(), report));
}
Event::SyncFailed(error) => self.account.sync_error = Some(error),
Event::Places(places) => self.account.browser.places_arrived(places),
Event::Folder { uid, entries } => self.account.browser.folder_arrived(uid, entries),
Event::BrowseFailed(error) => self.account.browser.failed(error),
} }
} }
// Pictures become textures a few per frame; the rest wait for the next. // Pictures become textures a few per frame; the rest wait for the next.
@@ -1369,6 +1412,20 @@ impl App {
self.form.error = None; self.form.error = None;
self.engine.add_share(name, url, password); self.engine.add_share(name, url, password);
} }
Action::AddAccountFolder { name, uid } => {
self.crawling = true;
self.engine.add_account_folder(name, uid);
}
Action::SignIn { username, password } => self.engine.sign_in(username, password),
Action::SubmitSecondFactor(code) => self.engine.submit_second_factor(code),
Action::SubmitMailboxPassword(password) => {
self.engine.submit_mailbox_password(password)
}
Action::CancelSignIn => self.engine.cancel_sign_in(),
Action::SignOut => self.engine.sign_out(),
Action::SyncNow => self.engine.sync_watch_history(),
Action::BrowsePlaces => self.engine.browse_places(),
Action::BrowseFolder(uid) => self.engine.browse_folder(uid),
Action::RemoveShare(id) => self.confirm_remove_share = Some(id), Action::RemoveShare(id) => self.confirm_remove_share = Some(id),
Action::ForgetShare(id) => { Action::ForgetShare(id) => {
self.confirm_remove_share = None; self.confirm_remove_share = None;
@@ -1868,6 +1925,7 @@ impl eframe::App for App {
view, view,
loaded, loaded,
form, form,
account,
matcher, matcher,
playback, playback,
opening, opening,
@@ -1964,7 +2022,9 @@ impl eframe::App for App {
}, },
&mut actions, &mut actions,
), ),
Page::Shares => ui::shares::show(ui, shares, library, form, &mut actions), Page::Shares => {
ui::shares::show(ui, shares, library, form, account, &mut actions)
}
Page::Settings => { Page::Settings => {
ui::settings::show(ui, settings, prefs, api_key, &mut actions) ui::settings::show(ui, settings, prefs, api_key, &mut actions)
} }
+307 -47
View File
@@ -25,6 +25,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel}; use std::sync::mpsc::{Receiver, Sender, channel};
use parking_lot::Mutex; use parking_lot::Mutex;
use pstr_core::account::{DriveEntry, DrivePlace, PendingSignIn, SignIn};
use pstr_core::appearance::Appearance; use pstr_core::appearance::Appearance;
use pstr_core::catalog::{ use pstr_core::catalog::{
Catalog, CatalogNode, OfflineFile, TitleTrackPrefs, WatchState, build_rows, Catalog, CatalogNode, OfflineFile, TitleTrackPrefs, WatchState, build_rows,
@@ -35,13 +36,15 @@ use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, Provider
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::ids::{LinkId, NodeUid, VolumeId}; use pstr_core::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use pstr_core::{Share, ShareStore, SharedLibrary}; use pstr_core::shares::AccountFolder;
use pstr_core::sync::{SyncReport, WatchSync};
use pstr_core::{Account, AccountStore, KeyringSecretStore, Share, ShareStore, SharedLibrary};
use pstr_meta::MetadataService; use pstr_meta::MetadataService;
use crate::desktop_prefs::{DesktopPrefs, DesktopPrefsFile}; use crate::desktop_prefs::{DesktopPrefs, DesktopPrefsFile};
use pstr_stream::{ use pstr_stream::{
BlockSource, DiskCacheConfig, FileBlocks, LibraryOpener, StreamConfig, StreamSource, BlockSource, COPY_BLOCKS_IN_FLIGHT, DiskCacheConfig, FileBlocks, LibraryOpener, StreamConfig,
VideoStream, StreamSource, VideoStream,
}; };
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
@@ -269,6 +272,38 @@ pub enum Event {
DownloadProgress(Box<DownloadItem>), DownloadProgress(Box<DownloadItem>),
/// Completed local copies, for badges and online-only actions. /// Completed local copies, for badges and online-only actions.
OfflineFiles(HashSet<DownloadKey>), OfflineFiles(HashSet<DownloadKey>),
/// The Proton account signed in or out, or a sign-in wants more.
Account(AccountStatus),
/// A sign-in step was refused, with why.
SignInFailed(String),
/// Watch history synced with the account's Drive.
Synced(SyncReport),
/// Watch history did not sync, with why. Not a toast: it is retried on a
/// timer, and an offline evening would be a stack of them.
SyncFailed(String),
/// The account's top-level places, for the Drive browser.
Places(Vec<DrivePlace>),
/// One folder of the account, for the Drive browser.
Folder {
uid: NodeUid,
entries: Vec<DriveEntry>,
},
/// The Drive browser's last request failed.
BrowseFailed(String),
}
/// Where the Proton account stands.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum AccountStatus {
/// The stored session has not been read yet.
#[default]
Unknown,
SignedOut,
SignedIn(String),
/// A sign-in is waiting for a second-factor code.
SecondFactor,
/// A sign-in is waiting for the mailbox password.
MailboxPassword,
} }
/// The share clients and the block layer, once they exist. /// The share clients and the block layer, once they exist.
@@ -335,6 +370,17 @@ pub struct Engine {
desktop_file: Arc<DesktopPrefsFile>, desktop_file: Arc<DesktopPrefsFile>,
/// Bumped to stop the crawls in flight; each watches it from its start. /// Bumped to stop the crawls in flight; each watches it from its start.
crawl_cancel: Arc<tokio::sync::watch::Sender<u64>>, crawl_cancel: Arc<tokio::sync::watch::Sender<u64>>,
accounts: AccountStore,
/// The signed-in Proton account, if any.
account: Arc<Mutex<Option<Account>>>,
/// False until the stored session has been read. Opening the shares waits
/// on it, or a launch would open the account's folders before the account
/// and report every one of them as failing to.
account_settled: Arc<tokio::sync::watch::Sender<bool>>,
/// A sign-in waiting for a code or the mailbox password.
pending_sign_in: Arc<Mutex<Option<PendingSignIn>>>,
/// `None` when this installation's sync identity could not be written.
watch_sync: Option<Arc<WatchSync>>,
events: Sender<Event>, events: Sender<Event>,
ctx: egui::Context, ctx: egui::Context,
} }
@@ -345,10 +391,10 @@ const THUMBNAIL_CONCURRENCY: usize = 6;
/// How many provider lookups may be in flight at once. See [`Engine::lookups`]. /// How many provider lookups may be in flight at once. See [`Engine::lookups`].
const LOOKUP_CONCURRENCY: usize = 2; const LOOKUP_CONCURRENCY: usize = 2;
const DOWNLOAD_CONCURRENCY: usize = 3; const DOWNLOAD_CONCURRENCY: usize = 3;
/// How many blocks of one download are fetched at once. Four is what the
/// stream layer itself allows a single read, for the same reason: enough to /// How often watch history is synced while the app is open, on top of at
/// hide a round trip, few enough that three downloads do not swamp a player. /// launch and whenever a player stops.
const DOWNLOAD_BLOCKS_IN_FLIGHT: usize = 4; const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5 * 60);
/// Enrichment as currently configured. /// Enrichment as currently configured.
struct Enrichment { struct Enrichment {
@@ -427,6 +473,11 @@ impl Engine {
Enrichment::build(config.clone()) Enrichment::build(config.clone())
}; };
let watch_sync = WatchSync::open(&dirs)
.inspect_err(|error| tracing::warn!("watch-history sync unavailable: {error}"))
.ok()
.map(Arc::new);
let track_prefs = catalog.all_title_track_prefs().unwrap_or_else(|error| { let track_prefs = catalog.all_title_track_prefs().unwrap_or_else(|error| {
tracing::warn!("read show track preferences: {error}"); tracing::warn!("read show track preferences: {error}");
HashMap::new() HashMap::new()
@@ -449,6 +500,11 @@ impl Engine {
download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)), download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)),
picture_shown: Arc::new(AtomicBool::new(false)), picture_shown: Arc::new(AtomicBool::new(false)),
crawl_cancel: Arc::new(tokio::sync::watch::channel(0).0), crawl_cancel: Arc::new(tokio::sync::watch::channel(0).0),
accounts: AccountStore::new(Arc::new(KeyringSecretStore)),
account: Arc::new(Mutex::new(None)),
account_settled: Arc::new(tokio::sync::watch::channel(false).0),
pending_sign_in: Arc::new(Mutex::new(None)),
watch_sync,
desktop: Arc::new(Mutex::new(desktop)), desktop: Arc::new(Mutex::new(desktop)),
desktop_file: Arc::new(desktop_file), desktop_file: Arc::new(desktop_file),
events, events,
@@ -460,6 +516,8 @@ impl Engine {
deferred.install_enrichment(Enrichment::build(config), true); deferred.install_enrichment(Enrichment::build(config), true);
}); });
} }
engine.load_account();
engine.sync_periodically();
Ok((engine, receiver)) Ok((engine, receiver))
} }
@@ -591,10 +649,239 @@ impl Engine {
}; };
engine.emit(Event::ShareAdded(share.name.clone())); engine.emit(Event::ShareAdded(share.name.clone()));
engine.load_shares(); engine.load_shares();
// The open connection predates the share, and crawling it through
// that would find it "not open".
engine.connection.lock().take();
engine.connect_and_crawl(Some(share.id)).await; engine.connect_and_crawl(Some(share.id)).await;
}); });
} }
/// Add a folder of the signed-in account as a share, then crawl it.
pub fn add_account_folder(&self, name: String, folder: NodeUid) {
let engine = self.clone();
self.runtime.spawn(async move {
let folder = AccountFolder {
volume_id: folder.volume_id.as_str().to_owned(),
link_id: folder.link_id.as_str().to_owned(),
};
let share = match engine.store.add_folder(&name, folder) {
Ok(share) => share,
Err(error) => return engine.emit(Event::ShareRejected(error.to_string())),
};
engine.emit(Event::ShareAdded(share.name.clone()));
engine.load_shares();
engine.connection.lock().take();
engine.connect_and_crawl(Some(share.id)).await;
});
}
// --------------------------------------------------------------- account
/// Read the stored session, off the UI thread — a keyring read can be a
/// D-Bus round trip to a keyring that has to unlock first.
fn load_account(&self) {
let engine = self.clone();
self.runtime.spawn_blocking(move || {
match engine.accounts.resume() {
Ok(Some(account)) => {
let username = account.username().to_owned();
*engine.account.lock() = Some(account);
engine.emit(Event::Account(AccountStatus::SignedIn(username)));
engine.sync_watch_history();
}
Ok(None) => engine.emit(Event::Account(AccountStatus::SignedOut)),
Err(error) => {
engine.emit(Event::Account(AccountStatus::SignedOut));
engine.fail("read the Proton session", error);
}
}
engine.account_settled.send_replace(true);
});
}
/// Start signing in. What happens next arrives as [`Event::Account`] or
/// [`Event::SignInFailed`].
pub fn sign_in(&self, username: String, password: String) {
let engine = self.clone();
self.runtime.spawn(async move {
match engine.accounts.sign_in(&username, &password, None).await {
Ok(step) => engine.sign_in_step(step),
Err(pstr_core::Error::HumanVerification(_)) => {
engine.emit(Event::SignInFailed(
"Proton wants a CAPTCHA before it accepts this sign-in, and this app \
cannot show one. Try again later, or sign in once from the Android app."
.to_owned(),
));
}
Err(error) => engine.emit(Event::SignInFailed(error.to_string())),
}
});
}
pub fn submit_second_factor(&self, code: String) {
let Some(pending) = self.pending_sign_in.lock().take() else {
return self.emit(Event::Account(AccountStatus::SignedOut));
};
let engine = self.clone();
self.runtime.spawn(async move {
match pending.second_factor(&code).await {
Ok(step) => engine.sign_in_step(step),
Err(error) => {
// The half-made session is spent; the viewer starts over.
engine.emit(Event::Account(AccountStatus::SignedOut));
engine.emit(Event::SignInFailed(error.to_string()));
}
}
});
}
pub fn submit_mailbox_password(&self, password: String) {
let engine = self.clone();
self.runtime.spawn(async move {
let Some(pending) = engine.pending_sign_in.lock().take() else {
return engine.emit(Event::Account(AccountStatus::SignedOut));
};
match pending.mailbox_password(&password).await {
Ok(account) => engine.signed_in(account),
Err(error) => {
// Kept: a mistyped mailbox password can be typed again.
*engine.pending_sign_in.lock() = Some(pending);
engine.emit(Event::SignInFailed(error.to_string()));
}
}
});
}
pub fn cancel_sign_in(&self) {
self.pending_sign_in.lock().take();
self.emit(Event::Account(AccountStatus::SignedOut));
}
fn sign_in_step(&self, step: SignIn) {
match step {
SignIn::Done(account) => self.signed_in(account),
SignIn::SecondFactor(pending) => {
*self.pending_sign_in.lock() = Some(pending);
self.emit(Event::Account(AccountStatus::SecondFactor));
}
SignIn::MailboxPassword(pending) => {
*self.pending_sign_in.lock() = Some(pending);
self.emit(Event::Account(AccountStatus::MailboxPassword));
}
}
}
fn signed_in(&self, account: Account) {
let username = account.username().to_owned();
*self.account.lock() = Some(account);
if let Some(sync) = &self.watch_sync {
sync.reset();
}
// Folders of the account that failed to open before are openable now.
self.connection.lock().take();
self.emit(Event::Account(AccountStatus::SignedIn(username)));
self.connect();
self.sync_watch_history();
}
/// Sign out and forget the stored session. The library keeps everything
/// it has; the account's folders stop opening until the next sign-in.
pub fn sign_out(&self) {
let engine = self.clone();
self.runtime.spawn(async move {
let account = engine.account.lock().take();
if let Err(error) = engine.accounts.sign_out(account.as_ref()).await {
engine.fail("forget the Proton session", error);
}
engine.signed_out();
});
}
fn signed_out(&self) {
self.account.lock().take();
if let Some(sync) = &self.watch_sync {
sync.reset();
}
self.connection.lock().take();
self.emit(Event::Account(AccountStatus::SignedOut));
}
/// Sync watch history now, if an account is signed in.
pub fn sync_watch_history(&self) {
let (Some(account), Some(sync)) = (self.account.lock().clone(), self.watch_sync.clone())
else {
return;
};
let engine = self.clone();
self.runtime.spawn(async move {
match sync.sync(&account, &engine.catalog).await {
Ok(report) => {
if report.applied > 0 {
engine.load_library();
}
engine.emit(Event::Synced(report));
}
Err(error) if error.is_signed_out() => {
// The refresh token died — revoked from another device, or
// unused for too long. Signing in again is the only fix,
// and a stored session that can never resume only makes
// every later launch fail the same way.
let _ = engine.accounts.sign_out(None).await;
engine.signed_out();
engine.emit(Event::Error(
"Your Proton session ended. Sign in again to keep syncing watch history."
.to_owned(),
));
}
Err(error) => {
tracing::warn!("sync watch history: {error}");
engine.emit(Event::SyncFailed(error.to_string()));
}
}
});
}
fn sync_periodically(&self) {
let engine = self.clone();
self.runtime.spawn(async move {
let mut ticks = tokio::time::interval(SYNC_INTERVAL);
// The first tick is immediate, and launch already syncs.
ticks.tick().await;
loop {
ticks.tick().await;
engine.sync_watch_history();
}
});
}
/// List the account's top-level places, for the Drive browser.
pub fn browse_places(&self) {
let Some(account) = self.account.lock().clone() else {
return self.emit(Event::BrowseFailed("not signed in".to_owned()));
};
let engine = self.clone();
self.runtime.spawn(async move {
match account.places().await {
Ok(places) => engine.emit(Event::Places(places)),
Err(error) => engine.emit(Event::BrowseFailed(error.to_string())),
}
});
}
/// List one folder of the account, for the Drive browser.
pub fn browse_folder(&self, uid: NodeUid) {
let Some(account) = self.account.lock().clone() else {
return self.emit(Event::BrowseFailed("not signed in".to_owned()));
};
let engine = self.clone();
self.runtime.spawn(async move {
match account.folder_children(&uid).await {
Ok(entries) => engine.emit(Event::Folder { uid, entries }),
Err(error) => engine.emit(Event::BrowseFailed(error.to_string())),
}
});
}
/// Forget a share and its stored secrets. /// Forget a share and its stored secrets.
pub fn remove_share(&self, id: String) { pub fn remove_share(&self, id: String) {
let engine = self.clone(); let engine = self.clone();
@@ -696,7 +983,11 @@ impl Engine {
return Some(opened); return Some(opened);
} }
let (library, failures) = match SharedLibrary::open_all(&self.store).await { let mut settled = self.account_settled.subscribe();
let _ = settled.wait_for(|settled| *settled).await;
let account = self.account.lock().clone();
let (library, failures) = match SharedLibrary::open_all(&self.store, account.as_ref()).await
{
Ok(opened) => opened, Ok(opened) => opened,
Err(error) => { Err(error) => {
self.emit(Event::ConnectFailed(error.to_string())); self.emit(Event::ConnectFailed(error.to_string()));
@@ -1555,8 +1846,8 @@ impl Engine {
return Ok(DownloadEnd::Cancelled); return Ok(DownloadEnd::Cancelled);
} }
let size = block_sizes[block_index]; let size = block_sizes[block_index];
let pending = let pending = fetches
fetches.get_or_insert_with(|| fetch_in_order(&stream, &block_sizes, block_index)); .get_or_insert_with(|| stream.blocks_in_order(block_index, COPY_BLOCKS_IN_FLIGHT));
let Some(bytes) = pending.try_next().await? else { let Some(bytes) = pending.try_next().await? else {
anyhow::bail!("block {block_index} never arrived"); anyhow::bail!("block {block_index} never arrived");
}; };
@@ -2043,27 +2334,25 @@ impl Engine {
return engine.emit(Event::Error(engine.no_service_reason())); return engine.emit(Event::Error(engine.no_service_reason()));
}; };
let name = found.name.clone(); let name = found.name.clone();
let record = service.chosen(title.key.clone(), found.clone()); let outcome = service.choose(&title, found).await;
if let Err(error) = engine.catalog.lock().set_metadata(&record) { if let Some(record) = &outcome.record
&& let Err(error) = engine.catalog.lock().set_metadata(record)
{
return engine.fail("store the match", error); return engine.fail("store the match", error);
} }
// A failure leaves the title un-enriched, and the next match run // A failure leaves the title un-enriched, and the next match run
// asks again. // asks again.
match service.enrich(&title, &found).await { if let Some(enrichment) = &outcome.enrichment {
Ok(enrichment) => {
let stored = engine.catalog.lock().set_enrichment( let stored = engine.catalog.lock().set_enrichment(
&title.key, &title.key,
service.provider(), service.provider(),
now(), now(),
&enrichment, enrichment,
); );
if let Err(error) = stored { if let Err(error) = stored {
tracing::warn!("store enrichment for {}: {error}", title.key); tracing::warn!("store enrichment for {}: {error}", title.key);
} }
} }
Err(error) => tracing::warn!("enrich {}: {error}", title.key),
}
engine.emit(Event::Status(format!("{} is now {name}", title.name))); engine.emit(Event::Status(format!("{} is now {name}", title.name)));
engine.load_metadata(); engine.load_metadata();
@@ -2223,35 +2512,6 @@ fn resume_position(existing: u64, block_sizes: &[u64]) -> (usize, u64) {
(block_sizes.len(), offset) (block_sizes.len(), offset)
} }
/// A file's blocks from `first` on, fetched several at a time and yielded in
/// file order — which is what the resume marker's "the length is the
/// boundary" rule needs. One at a time, a download runs at one block per round
/// trip whatever the link can carry.
fn fetch_in_order(
stream: &VideoStream,
block_sizes: &[u64],
first: usize,
) -> futures::stream::BoxStream<'static, pstr_stream::Result<Vec<u8>>> {
use futures::StreamExt as _;
let start: u64 = block_sizes[..first].iter().sum();
let ranges: Vec<(u64, u64)> = block_sizes[first..]
.iter()
.scan(start, |offset, size| {
let range = (*offset, *size);
*offset += size;
Some(range)
})
.collect();
let stream = stream.clone();
futures::stream::iter(ranges)
.map(move |(offset, size)| {
let stream = stream.clone();
async move { stream.read_range(offset, size).await }
})
.buffered(DOWNLOAD_BLOCKS_IN_FLIGHT)
.boxed()
}
async fn wait_until_runnable(command: &mut tokio::sync::watch::Receiver<DownloadCommand>) -> bool { async fn wait_until_runnable(command: &mut tokio::sync::watch::Receiver<DownloadCommand>) -> bool {
loop { loop {
let current = *command.borrow_and_update(); let current = *command.borrow_and_update();
+596
View File
@@ -0,0 +1,596 @@
//! The Proton account, on the shares page: signing in, watch-history sync, and
//! picking folders of the viewer's own Drive as library shares.
//!
//! Optional and said so: public links need no account, and the page reads the
//! same without one. What signing in adds is spelled out next to the form,
//! because "sign in" alone does not say why an app that streams public links
//! would want a password.
use pstr_core::account::{DriveEntry, DrivePlace, PlaceKind};
use pstr_core::proton_sdk::ids::NodeUid;
use pstr_core::sync::SyncReport;
use crate::app::Action;
use crate::engine::AccountStatus;
use crate::theme;
use crate::ui;
/// What the account section holds between frames.
#[derive(Default)]
pub struct AccountPanel {
pub status: AccountStatus,
pub username: String,
pub password: String,
pub code: String,
pub mailbox: String,
/// A request is out and not answered yet.
pub busy: bool,
/// Why the last sign-in step was refused.
pub error: Option<String>,
/// When watch history last synced, in Unix seconds, and what it did.
pub synced: Option<(i64, SyncReport)>,
/// Why the last sync failed, until one succeeds.
pub sync_error: Option<String>,
pub browser: DriveBrowser,
}
impl AccountPanel {
/// Apply where the account now stands. The typed secrets go as soon as
/// they are not needed: a password has no business sitting in a struct for
/// the rest of the session.
pub fn set_status(&mut self, status: AccountStatus) {
self.busy = false;
match &status {
AccountStatus::SignedIn(_) | AccountStatus::SignedOut => {
self.password.clear();
self.code.clear();
self.mailbox.clear();
if matches!(status, AccountStatus::SignedIn(_)) {
self.error = None;
} else {
self.browser = DriveBrowser::default();
self.synced = None;
self.sync_error = None;
}
}
AccountStatus::SecondFactor => self.error = None,
AccountStatus::MailboxPassword => {
self.code.clear();
self.error = None;
}
AccountStatus::Unknown => {}
}
self.status = status;
}
pub fn refused(&mut self, error: String) {
self.busy = false;
self.error = Some(error);
}
}
/// Where the Drive browser is.
#[derive(Default)]
pub struct DriveBrowser {
pub open: bool,
/// The folders descended through, each by name. Empty means the places.
pub trail: Vec<(String, NodeUid)>,
pub places: Option<Vec<DrivePlace>>,
/// The listing of the folder at the end of the trail, once it arrives.
pub entries: Option<(NodeUid, Vec<DriveEntry>)>,
pub loading: bool,
pub error: Option<String>,
}
impl DriveBrowser {
pub fn places_arrived(&mut self, places: Vec<DrivePlace>) {
self.loading = false;
self.error = None;
self.places = Some(places);
}
/// Take a listing, unless the viewer has already moved on from it.
pub fn folder_arrived(&mut self, uid: NodeUid, entries: Vec<DriveEntry>) {
if self
.trail
.last()
.is_some_and(|(_, current)| *current == uid)
{
self.loading = false;
self.error = None;
self.entries = Some((uid, entries));
}
}
pub fn failed(&mut self, error: String) {
self.loading = false;
self.error = Some(error);
}
fn enter(&mut self, name: String, uid: NodeUid, actions: &mut Vec<Action>) {
self.trail.push((name, uid.clone()));
self.entries = None;
self.loading = true;
self.error = None;
actions.push(Action::BrowseFolder(uid));
}
fn up(&mut self, actions: &mut Vec<Action>) {
self.trail.pop();
self.entries = None;
self.error = None;
match self.trail.last() {
Some((_, uid)) => {
self.loading = true;
actions.push(Action::BrowseFolder(uid.clone()));
}
None => self.loading = self.places.is_none(),
}
}
}
/// The account card: a sign-in form, or who is signed in and how sync is doing.
pub fn section(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
ui::section(ui, "Proton account");
ui.label(ui::muted(
"Optional. Signed in, your watch history is kept in your own Drive and every device \
resumes where another left off, and folders of your Drive — your files, other \
devices, what was shared with you — can be added to the library.",
));
ui.add_space(theme::space::L);
card(ui, |ui| match panel.status.clone() {
AccountStatus::Unknown => {
ui.horizontal(|ui| {
ui.add(egui::Spinner::new().size(14.0));
ui.label(ui::muted("reading the stored session…"));
});
}
AccountStatus::SignedOut => sign_in_form(ui, panel, actions),
AccountStatus::SecondFactor => code_form(ui, panel, actions),
AccountStatus::MailboxPassword => mailbox_form(ui, panel, actions),
AccountStatus::SignedIn(username) => signed_in(ui, panel, &username, actions),
});
}
fn card(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) {
egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
ui.set_width(ui.available_width().min(640.0));
add(ui);
});
}
fn sign_in_form(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
ui.label(ui::muted("Address"));
ui.add(
egui::TextEdit::singleline(&mut panel.username)
.hint_text("you@proton.me")
.desired_width(f32::INFINITY),
);
ui.add_space(theme::space::M);
ui.label(ui::muted("Password"));
let field = ui.add(
egui::TextEdit::singleline(&mut panel.password)
.password(true)
.desired_width(f32::INFINITY),
);
ui.add_space(theme::space::L);
error_line(ui, panel);
let ready = !panel.username.trim().is_empty() && !panel.password.is_empty() && !panel.busy;
let submitted = field.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
ui.horizontal(|ui| {
let clicked = ui
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Sign in"))
.inner
.clicked();
if ready && (clicked || submitted) {
panel.busy = true;
panel.error = None;
actions.push(Action::SignIn {
username: panel.username.trim().to_owned(),
password: panel.password.clone(),
});
}
busy_line(ui, panel, "signing in…");
});
}
fn code_form(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
ui.label(ui::muted("Two-factor code from your authenticator app"));
let field = ui.add(
egui::TextEdit::singleline(&mut panel.code)
.hint_text("123456")
.desired_width(160.0),
);
field.request_focus();
ui.add_space(theme::space::L);
error_line(ui, panel);
let ready = !panel.code.trim().is_empty() && !panel.busy;
let submitted = field.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
ui.horizontal(|ui| {
let clicked = ui
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Continue"))
.inner
.clicked();
if ready && (clicked || submitted) {
panel.busy = true;
actions.push(Action::SubmitSecondFactor(panel.code.trim().to_owned()));
}
if ui.button("Cancel").clicked() {
actions.push(Action::CancelSignIn);
}
busy_line(ui, panel, "checking…");
});
}
fn mailbox_form(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
ui.label(ui::muted(
"This account has a separate mailbox password. It unlocks your files, and is kept \
in your system keyring.",
));
ui.add_space(theme::space::S);
let field = ui.add(
egui::TextEdit::singleline(&mut panel.mailbox)
.password(true)
.hint_text("Mailbox password")
.desired_width(f32::INFINITY),
);
ui.add_space(theme::space::L);
error_line(ui, panel);
let ready = !panel.mailbox.is_empty() && !panel.busy;
let submitted = field.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
ui.horizontal(|ui| {
let clicked = ui
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Unlock"))
.inner
.clicked();
if ready && (clicked || submitted) {
panel.busy = true;
actions.push(Action::SubmitMailboxPassword(panel.mailbox.clone()));
}
if ui.button("Cancel").clicked() {
actions.push(Action::CancelSignIn);
}
busy_line(ui, panel, "unlocking…");
});
}
fn signed_in(
ui: &mut egui::Ui,
panel: &mut AccountPanel,
username: &str,
actions: &mut Vec<Action>,
) {
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label(
egui::RichText::new(format!(
"{} {username}",
egui_phosphor::regular::USER_CIRCLE
))
.strong(),
);
let status = match (&panel.sync_error, &panel.synced) {
(Some(error), _) => format!("Watch history did not sync: {error}"),
(None, Some((at, report))) => sync_line(*at, report, unix_now()),
(None, None) => "Watch history syncs with your Drive".to_owned(),
};
ui.label(ui::muted(status));
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(
egui::Button::new(format!("{} Sign out", egui_phosphor::regular::SIGN_OUT))
.fill(theme::card_hover()),
)
.clicked()
{
actions.push(Action::SignOut);
}
if ui
.button(format!(
"{} Sync now",
egui_phosphor::regular::ARROWS_CLOCKWISE
))
.clicked()
{
actions.push(Action::SyncNow);
}
});
});
}
/// "Synced 3 min ago · 2 positions from other devices".
fn sync_line(at: i64, report: &SyncReport, now: i64) -> String {
let ago = (now - at).max(0);
let when = match ago {
0..60 => "just now".to_owned(),
60..3600 => format!("{} min ago", ago / 60),
_ => format!("{} h ago", ago / 3600),
};
let mut line = format!("Synced {when}");
if report.applied > 0 {
line.push_str(&format!(
" · {} from other devices",
ui::library::plural(report.applied, "position")
));
}
line
}
fn error_line(ui: &mut egui::Ui, panel: &AccountPanel) {
if let Some(error) = &panel.error {
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
ui.add_space(theme::space::S);
}
}
fn busy_line(ui: &mut egui::Ui, panel: &AccountPanel, text: &str) {
if panel.busy {
ui.add(egui::Spinner::new().size(14.0));
ui.label(ui::muted(text));
}
}
/// Browse the account's Drive and add a folder of it to the library.
pub fn browser(
ui: &mut egui::Ui,
panel: &mut AccountPanel,
shares: &[pstr_core::Share],
actions: &mut Vec<Action>,
) {
if !matches!(panel.status, AccountStatus::SignedIn(_)) {
return;
}
ui.add_space(theme::space::XXL);
ui::section(ui, "Add from your Drive");
let browser = &mut panel.browser;
if !browser.open {
ui.label(ui::muted(
"A folder of your own Drive becomes a share like any link: crawled, browsable and \
streamed through your account.",
));
ui.add_space(theme::space::L);
if ui
.button(format!(
"{} Browse your Drive",
egui_phosphor::regular::FOLDER
))
.clicked()
{
browser.open = true;
if browser.places.is_none() {
browser.loading = true;
actions.push(Action::BrowsePlaces);
}
}
return;
}
card(ui, |ui| {
ui.horizontal(|ui| {
if !browser.trail.is_empty()
&& ui
.button(egui_phosphor::regular::ARROW_LEFT)
.on_hover_text("Up a folder")
.clicked()
{
browser.up(actions);
}
let mut crumbs = vec!["Drive".to_owned()];
crumbs.extend(browser.trail.iter().map(|(name, _)| name.clone()));
ui.label(
egui::RichText::new(
crumbs.join(&format!(" {} ", egui_phosphor::regular::CARET_RIGHT)),
)
.strong(),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Close").clicked() {
browser.open = false;
}
});
});
ui.add_space(theme::space::M);
if let Some(error) = &browser.error {
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
ui.add_space(theme::space::S);
}
if browser.loading {
ui.horizontal(|ui| {
ui.add(egui::Spinner::new().size(14.0));
ui.label(ui::muted("listing…"));
});
return;
}
let mut entered = None;
egui::ScrollArea::vertical()
.max_height(320.0)
.auto_shrink([false, true])
.show(ui, |ui| match browser.trail.last() {
None => {
for place in browser.places.iter().flatten() {
let icon = match place.kind {
PlaceKind::MyFiles => egui_phosphor::regular::HARD_DRIVES,
PlaceKind::Device => egui_phosphor::regular::DESKTOP,
PlaceKind::SharedWithMe => egui_phosphor::regular::USERS,
};
if row(ui, icon, &place.name, None, true).clicked() {
entered = Some((place.name.clone(), place.uid.clone()));
}
}
}
Some(_) => {
let entries = browser
.entries
.as_ref()
.map(|(_, entries)| entries.as_slice());
if entries.is_some_and(<[DriveEntry]>::is_empty) {
ui.label(ui::muted("This folder is empty."));
}
for entry in entries.unwrap_or_default() {
let (icon, detail) = if entry.is_folder {
(egui_phosphor::regular::FOLDER, None)
} else if entry
.media_type
.as_deref()
.is_some_and(|media| media.starts_with("video/"))
{
(
egui_phosphor::regular::FILE_VIDEO,
entry.size.map(format_size),
)
} else {
(egui_phosphor::regular::FILE, entry.size.map(format_size))
};
if row(ui, icon, &entry.name, detail.as_deref(), entry.is_folder).clicked()
&& entry.is_folder
{
entered = Some((entry.name.clone(), entry.uid.clone()));
}
}
}
});
if let Some((name, uid)) = entered {
browser.enter(name, uid, actions);
}
if let Some((name, uid)) = browser.trail.last() {
ui.add_space(theme::space::L);
let added = shares.iter().any(|share| {
share
.folder
.as_ref()
.is_some_and(|folder| folder.uid() == *uid)
});
ui.horizontal(|ui| {
if added {
ui.label(ui::muted("This folder is already in the library."));
} else if ui::accent_button(ui, "Add this folder").clicked() {
actions.push(Action::AddAccountFolder {
name: name.clone(),
uid: uid.clone(),
});
browser.open = false;
}
});
}
});
}
/// One line of the browser. Only folders and places are clickable.
fn row(
ui: &mut egui::Ui,
icon: &str,
name: &str,
detail: Option<&str>,
clickable: bool,
) -> egui::Response {
let mut text = egui::text::LayoutJob::default();
let body = ui.style().text_styles[&egui::TextStyle::Body].clone();
text.append(
&format!("{icon} {name}"),
0.0,
egui::TextFormat::simple(
body.clone(),
if clickable {
theme::text()
} else {
theme::muted()
},
),
);
if let Some(detail) = detail {
text.append(
&format!(" {detail}"),
0.0,
egui::TextFormat::simple(body, theme::muted()),
);
}
let sense = if clickable {
egui::Sense::click()
} else {
egui::Sense::hover()
};
let response = ui.add(egui::Label::new(text).sense(sense).truncate());
if clickable && response.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
response
}
fn format_size(bytes: i64) -> String {
let bytes = bytes.max(0) as f64;
const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
const MIB: f64 = 1024.0 * 1024.0;
if bytes >= GIB {
format!("{:.1} GiB", bytes / GIB)
} else {
format!("{:.0} MiB", bytes / MIB)
}
}
pub(crate) fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|since| since.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_sync_line_says_how_long_ago_and_what_came_in() {
let quiet = SyncReport::default();
assert_eq!(sync_line(100, &quiet, 130), "Synced just now");
assert_eq!(sync_line(100, &quiet, 400), "Synced 5 min ago");
let busy = SyncReport {
applied: 2,
..SyncReport::default()
};
assert_eq!(
sync_line(0, &busy, 7200),
"Synced 2 h ago · 2 positions from other devices"
);
}
#[test]
fn signing_out_forgets_what_was_typed_and_browsed() {
let mut panel = AccountPanel {
password: "secret".into(),
mailbox: "also secret".into(),
..AccountPanel::default()
};
panel.browser.open = true;
panel.set_status(AccountStatus::SignedOut);
assert!(panel.password.is_empty() && panel.mailbox.is_empty());
assert!(!panel.browser.open);
}
#[test]
fn a_listing_for_a_folder_already_left_is_dropped() {
let uid = |link: &str| {
NodeUid::new(
pstr_core::proton_sdk::ids::VolumeId::new("v".to_owned()),
pstr_core::proton_sdk::ids::LinkId::new(link.to_owned()),
)
};
let mut browser = DriveBrowser {
trail: vec![("b".into(), uid("b"))],
loading: true,
..DriveBrowser::default()
};
browser.folder_arrived(uid("a"), Vec::new());
assert!(browser.loading && browser.entries.is_none());
browser.folder_arrived(uid("b"), Vec::new());
assert!(!browser.loading && browser.entries.is_some());
}
}
+1
View File
@@ -3,6 +3,7 @@
//! mutating the library it is iterating: pages collect [`Action`]s and the app //! mutating the library it is iterating: pages collect [`Action`]s and the app
//! applies them after the frame is drawn. //! applies them after the frame is drawn.
pub mod account;
pub mod downloads; pub mod downloads;
pub mod history; pub mod history;
pub mod library; pub mod library;
+9
View File
@@ -18,6 +18,7 @@ pub fn show(
shares: &[Share], shares: &[Share],
library: &Library, library: &Library,
form: &mut ShareForm, form: &mut ShareForm,
account: &mut ui::account::AccountPanel,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
@@ -40,6 +41,10 @@ pub fn show(
ui.add_space(theme::space::XXL); ui.add_space(theme::space::XXL);
add_form(ui, shares, form, actions); add_form(ui, shares, form, actions);
ui.add_space(theme::space::XXL);
ui::account::section(ui, account, actions);
ui::account::browser(ui, account, shares, actions);
}); });
} }
@@ -56,6 +61,9 @@ fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<
// What is in it, rather than what it is called inside the // What is in it, rather than what it is called inside the
// app. The id is still a hover away for a bug report. // app. The id is still a hover away for a bug report.
let mut detail = ui::library::plural(titles, "title"); let mut detail = ui::library::plural(titles, "title");
if share.folder.is_some() {
detail.push_str(" · from your Drive");
}
if share.has_custom_password { if share.has_custom_password {
detail.push_str(" · password protected"); detail.push_str(" · password protected");
} }
@@ -211,6 +219,7 @@ mod tests {
name: "anime".into(), name: "anime".into(),
token: token.into(), token: token.into(),
has_custom_password: false, has_custom_password: false,
folder: None,
} }
} }
+1
View File
@@ -23,5 +23,6 @@ tokio.workspace = true
anyhow.workspace = true anyhow.workspace = true
clap.workspace = true clap.workspace = true
rpassword.workspace = true rpassword.workspace = true
parking_lot.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
+186 -4
View File
@@ -8,9 +8,12 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use proton_sdk::ids::{LinkId, NodeUid, VolumeId}; use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use pstr_core::account::{PlaceKind, SignIn};
use pstr_core::catalog::{Catalog, build_rows}; use pstr_core::catalog::{Catalog, build_rows};
use pstr_core::config::AppDirs; use pstr_core::config::AppDirs;
use pstr_core::{ShareStore, SharedLibrary}; use pstr_core::shares::AccountFolder;
use pstr_core::sync::WatchSync;
use pstr_core::{Account, AccountStore, KeyringSecretStore, ShareStore, SharedLibrary};
use pstr_player::{EndReason, Player, PlayerConfig, PlayerEvent}; use pstr_player::{EndReason, Player, PlayerConfig, PlayerEvent};
use pstr_stream::{DiskCacheConfig, LibraryOpener, StreamConfig, StreamSource}; use pstr_stream::{DiskCacheConfig, LibraryOpener, StreamConfig, StreamSource};
@@ -69,8 +72,38 @@ enum Command {
#[arg(long)] #[arg(long)]
custom_password: bool, custom_password: bool,
}, },
/// Add a folder of the signed-in account as a share.
///
/// The folder is named by the `VOLUME:LINK` id `browse` prints.
AddFolder {
/// What to call it in the UI.
#[arg(long)]
name: String,
/// The folder, as `VOLUME:LINK`.
folder: String,
},
/// List configured shares. /// List configured shares.
Shares, Shares,
/// Sign in to a Proton account, for watch history across devices and for
/// folders of your own Drive.
///
/// The password, a second-factor code and a mailbox password are prompted
/// for as the account needs them.
Login {
/// The account's address.
username: String,
},
/// Sign out and forget the stored session.
Logout,
/// Show which account is signed in.
Account,
/// List the account's Drive: its top-level places, or one folder.
Browse {
/// The folder to list, as `VOLUME:LINK`; the places when omitted.
folder: Option<String>,
},
/// Sync watch history with the signed-in account's Drive.
Sync,
/// Forget a share and its stored credentials. /// Forget a share and its stored credentials.
Remove { Remove {
/// The share id, as printed by `shares`. /// The share id, as printed by `shares`.
@@ -210,6 +243,7 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
let dirs = AppDirs::ensure().context("resolve app directories")?; let dirs = AppDirs::ensure().context("resolve app directories")?;
let store = ShareStore::new(dirs.clone()); let store = ShareStore::new(dirs.clone());
let accounts = AccountStore::new(std::sync::Arc::new(KeyringSecretStore));
match cli.command { match cli.command {
Command::Add { Command::Add {
@@ -228,6 +262,80 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
println!("added {} ({})", share.name, share.id); println!("added {} ({})", share.name, share.id);
} }
Command::AddFolder { name, folder } => {
let uid = parse_uid(&folder)?;
let share = store
.add_folder(
&name,
AccountFolder {
volume_id: uid.volume_id.as_str().to_owned(),
link_id: uid.link_id.as_str().to_owned(),
},
)
.context("add folder")?;
println!("added {} ({})", share.name, share.id);
}
Command::Login { username } => login(&accounts, &username).await?,
Command::Logout => {
let account = accounts.resume().ok().flatten();
accounts
.sign_out(account.as_ref())
.await
.context("sign out")?;
println!("signed out");
}
Command::Account => match accounts.username().context("read the account")? {
Some(username) => println!("signed in as {username}"),
None => println!("not signed in — sign in with `pstr login <address>`"),
},
Command::Browse { folder } => {
let account = signed_in(&accounts)?;
match folder {
None => {
for place in account.places().await.context("list places")? {
let kind = match place.kind {
PlaceKind::MyFiles => "files",
PlaceKind::Device => "device",
PlaceKind::SharedWithMe => "shared",
};
println!("{}\t{kind}\t{}", format_uid(&place.uid), place.name);
}
}
Some(folder) => {
let entries = account
.folder_children(&parse_uid(&folder)?)
.await
.context("list folder")?;
for entry in entries {
let kind = if entry.is_folder { "dir" } else { "file" };
println!("{}\t{kind}\t{}", format_uid(&entry.uid), entry.name);
}
}
}
}
Command::Sync => {
let account = signed_in(&accounts)?;
let sync = WatchSync::open(&dirs).context("open sync state")?;
let catalog =
parking_lot::Mutex::new(Catalog::open(&dirs.catalog_db()).context("open catalog")?);
let report = sync.sync(&account, &catalog).await.context("sync")?;
println!(
"read {} other device(s), took {} newer position(s), {}",
report.devices,
report.applied,
if report.uploaded {
"uploaded this device's history"
} else {
"this device's history was already up to date"
}
);
}
Command::Shares => { Command::Shares => {
let shares = store.list().context("list shares")?; let shares = store.list().context("list shares")?;
if shares.is_empty() { if shares.is_empty() {
@@ -239,7 +347,12 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
} else { } else {
"" ""
}; };
println!("{}\t{}{}", share.id, share.name, custom); let kind = if share.folder.is_some() {
" [account folder]"
} else {
""
};
println!("{}\t{}{}{}", share.id, share.name, custom, kind);
} }
} }
@@ -249,7 +362,8 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
} }
Command::Crawl { share } => { Command::Crawl { share } => {
let (library, failures) = SharedLibrary::open_all(&store) let account = accounts.resume().context("resume the account")?;
let (library, failures) = SharedLibrary::open_all(&store, account.as_ref())
.await .await
.context("open shares")?; .context("open shares")?;
for (share, error) in &failures { for (share, error) in &failures {
@@ -643,7 +757,10 @@ async fn open_target(
println!("file: {} ({})", target.name, target.share_id); println!("file: {} ({})", target.name, target.share_id);
let (library, failures) = SharedLibrary::open_all(store) let account = AccountStore::new(std::sync::Arc::new(KeyringSecretStore))
.resume()
.context("resume the account")?;
let (library, failures) = SharedLibrary::open_all(store, account.as_ref())
.await .await
.context("open shares")?; .context("open shares")?;
for (share, error) in &failures { for (share, error) in &failures {
@@ -968,6 +1085,71 @@ async fn play(
} }
/// Unix seconds, for the "when was this asked" columns. /// Unix seconds, for the "when was this asked" columns.
/// Walk an interactive sign-in through whatever the account asks for.
async fn login(accounts: &AccountStore, username: &str) -> Result<()> {
let password = read_password("Password: ")?;
let mut step = match accounts.sign_in(username, &password, None).await {
Ok(step) => step,
Err(pstr_core::Error::HumanVerification(challenge)) => {
// Solving a CAPTCHA needs a browser engine that can hand the
// token back, which a terminal does not have.
anyhow::bail!(
"Proton wants a CAPTCHA before it accepts this sign-in. Sign in once in the \
desktop or Android app, or try again later. ({})",
challenge.verification_url()
);
}
Err(error) => return Err(error).context("sign in"),
};
loop {
step = match step {
SignIn::Done(account) => {
println!("signed in as {}", account.username());
return Ok(());
}
SignIn::SecondFactor(pending) => {
let code = read_password("Two-factor code: ")?;
pending
.second_factor(&code)
.await
.context("second factor")?
}
SignIn::MailboxPassword(pending) => {
let mailbox = read_password("Mailbox password: ")?;
SignIn::Done(
pending
.mailbox_password(&mailbox)
.await
.context("mailbox password")?,
)
}
};
}
}
fn signed_in(accounts: &AccountStore) -> Result<Account> {
accounts
.resume()
.context("resume the account")?
.ok_or_else(|| anyhow::anyhow!("not signed in — sign in with `pstr login <address>`"))
}
/// A node as `browse` prints it and `add-folder` takes it.
fn format_uid(uid: &NodeUid) -> String {
format!("{}:{}", uid.volume_id.as_str(), uid.link_id.as_str())
}
fn parse_uid(text: &str) -> Result<NodeUid> {
let (volume, link) = text
.split_once(':')
.filter(|(volume, link)| !volume.is_empty() && !link.is_empty())
.ok_or_else(|| anyhow::anyhow!("expected VOLUME:LINK, as `pstr browse` prints"))?;
Ok(NodeUid::new(
VolumeId::new(volume.to_owned()),
LinkId::new(link.to_owned()),
))
}
fn unix_now() -> i64 { fn unix_now() -> i64 {
std::time::SystemTime::now() std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
+2
View File
@@ -20,6 +20,8 @@ tracing.workspace = true
directories.workspace = true directories.workspace = true
rusqlite.workspace = true rusqlite.workspace = true
sha2.workspace = true sha2.workspace = true
parking_lot.workspace = true
getrandom.workspace = true
# The credential store, per platform. Without the target's feature `keyring` # The credential store, per platform. Without the target's feature `keyring`
# compiles fine and silently resolves to its in-memory `mock` store, which # compiles fine and silently resolves to its in-memory `mock` store, which
+635
View File
@@ -0,0 +1,635 @@
//! Signing in to a Proton account, and keeping the session.
//!
//! The rest of the app works without one — public links need no account. A
//! signed-in account adds two things: folders of the viewer's own Drive (My
//! Files, other devices, what was shared with them) as library shares, and
//! watch history kept in their Drive so every device resumes where another
//! left off ([`crate::sync`]).
//!
//! ## What is kept, and where
//!
//! Following `proton-drive-linux`: one JSON blob in the [`SecretStore`] holding
//! the session tokens, the mailbox password and the account's key salts. The
//! mailbox password is what unlocks the key chain, so without it a resumed
//! session could list nothing. The key salts are kept because
//! `core/v4/keys/salts` needs a scope only a password sign-in grants: after the
//! first token refresh it answers 403, and a session resumed without them
//! could never unlock its keys again.
//!
//! Proton refresh tokens are single-use, so every refresh is written back
//! (see [`persist_refreshed_tokens`]). A refresh that is not leaves the store
//! holding a dead token and the next launch signed out.
//!
//! ## Signing in is several steps
//!
//! Password, then maybe a second-factor code, then — on an account with a
//! separate mailbox password — that. Each front end asks for them in its own
//! way, so [`AccountStore::sign_in`] returns a [`SignIn`] saying what it needs
//! next and holding the half-made session until it is given.
use std::sync::Arc;
use proton_drive_rs::{KeySalt, NodeKind, ProtonDriveClient};
use proton_sdk::api::HumanVerificationCredential;
use proton_sdk::error::ProtonError;
use proton_sdk::ids::NodeUid;
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::shares::{SecretStore, client_configuration};
/// The secret-store key the session is kept under. Share secrets are keyed
/// `share-…`, so the two cannot collide.
const SESSION_KEY: &str = "proton-account";
/// Blocks in flight for streaming from the account, as for a public link —
/// see `ShareStore::open`.
const MAX_INFLIGHT_BLOCKS: usize = 48;
/// Everything needed to resume the session unattended.
#[derive(Serialize, Deserialize, Clone)]
struct StoredSession {
session_id: String,
username: String,
user_id: String,
access_token: String,
refresh_token: String,
scopes: Vec<String>,
/// `1` single password, `2` separate mailbox password — Proton's values.
password_mode: u8,
mailbox_password: String,
key_salts: Vec<KeySalt>,
}
impl StoredSession {
fn capture(
session: &ProtonApiSession,
tokens: proton_sdk::http::Tokens,
mailbox_password: &str,
key_salts: Vec<KeySalt>,
) -> Self {
Self {
session_id: session.session_id().as_str().to_owned(),
username: session.username().to_owned(),
user_id: session.user_id().as_str().to_owned(),
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
scopes: session.scopes().to_vec(),
password_mode: match session.password_mode() {
PasswordMode::Single => 1,
PasswordMode::Dual => 2,
},
mailbox_password: mailbox_password.to_owned(),
key_salts,
}
}
fn resume_parameters(&self) -> ResumeParameters {
ResumeParameters {
session_id: self.session_id.clone().into(),
username: self.username.clone(),
user_id: self.user_id.clone().into(),
access_token: self.access_token.clone(),
refresh_token: self.refresh_token.clone(),
scopes: self.scopes.clone(),
is_waiting_for_second_factor_code: false,
password_mode: match self.password_mode {
1 => PasswordMode::Single,
_ => PasswordMode::Dual,
},
}
}
}
/// A signed-in account, ready for Drive work.
///
/// Cheap to clone; clones share one session and one set of caches.
#[derive(Clone)]
pub struct Account {
session: ProtonApiSession,
drive: ProtonDriveClient,
}
impl Account {
/// The address the viewer signed in with.
pub fn username(&self) -> &str {
self.session.username()
}
/// The Drive client the account's folders and watch history go through.
pub fn drive(&self) -> &ProtonDriveClient {
&self.drive
}
/// Where a library folder can come from: My Files, each device, and each
/// folder someone shared with the viewer.
///
/// The device this app keeps watch history in is left out — it holds
/// nothing to watch. A device or share whose name will not decrypt is
/// still listed, under a placeholder, rather than hidden: it is the
/// viewer's, and they can still open it.
pub async fn places(&self) -> Result<Vec<DrivePlace>> {
let mut places = Vec::new();
let my_files = self.drive.get_my_files_folder().await?;
places.push(DrivePlace {
kind: PlaceKind::MyFiles,
name: "My files".to_owned(),
uid: my_files.uid,
});
let mut devices = self.drive.enumerate_devices().await?;
devices.sort_by_key(|device| device.creation_time);
for device in devices {
let name = device.name.unwrap_or_else(|_| "Unnamed device".to_owned());
if name == crate::sync::DEVICE_NAME {
continue;
}
places.push(DrivePlace {
kind: PlaceKind::Device,
name,
uid: device.root_folder_uid,
});
}
let shared: Vec<NodeUid> = self
.drive
.enumerate_shared_with_me()
.await?
.into_iter()
.map(|item| item.uid)
.collect();
if !shared.is_empty() {
let mut nodes = self.drive.enumerate_nodes(&shared).await?;
nodes.retain(|node| node.is_folder() && !node.trashed);
nodes.sort_by_key(|node| node.name.to_lowercase());
places.extend(nodes.into_iter().map(|node| DrivePlace {
kind: PlaceKind::SharedWithMe,
name: node.name,
uid: node.uid,
}));
}
Ok(places)
}
/// What one folder of the account holds: folders first, then files, each
/// by name. Trashed nodes are left out.
pub async fn folder_children(&self, folder: &NodeUid) -> Result<Vec<DriveEntry>> {
let uids = self
.drive
.enumerate_folder_children_node_uids(folder)
.await?;
if uids.is_empty() {
return Ok(Vec::new());
}
let mut entries: Vec<DriveEntry> = self
.drive
.enumerate_nodes(&uids)
.await?
.into_iter()
.filter(|node| !node.trashed)
.map(|node| {
let (size, media_type) = match &node.kind {
NodeKind::File {
media_type,
claimed_size,
..
} => (*claimed_size, Some(media_type.clone())),
_ => (None, None),
};
DriveEntry {
is_folder: node.is_folder(),
uid: node.uid,
name: node.name,
size,
media_type,
}
})
.collect();
entries.sort_by(|a, b| {
b.is_folder
.cmp(&a.is_folder)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
Ok(entries)
}
/// End the session on Proton's side. Best-effort: a session that is
/// already dead has nothing to end.
async fn end(&self) {
if let Err(error) = self.session.end().await {
tracing::debug!(%error, "ending the Proton session failed");
}
}
}
/// Which kind of place a [`DrivePlace`] is.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlaceKind {
MyFiles,
Device,
SharedWithMe,
}
/// A top-level place in the account's Drive.
#[derive(Debug, Clone)]
pub struct DrivePlace {
pub kind: PlaceKind,
pub name: String,
pub uid: NodeUid,
}
/// One node of a folder listing.
#[derive(Debug, Clone)]
pub struct DriveEntry {
pub uid: NodeUid,
pub name: String,
pub is_folder: bool,
/// Plaintext size, when the uploader recorded one.
pub size: Option<i64>,
pub media_type: Option<String>,
}
/// Where a sign-in stands.
pub enum SignIn {
/// Signed in; the session is stored.
Done(Account),
/// The account has two-factor authentication: ask for a code and pass it to
/// [`PendingSignIn::second_factor`].
SecondFactor(PendingSignIn),
/// The account has a separate mailbox password: ask for it and pass it to
/// [`PendingSignIn::mailbox_password`].
MailboxPassword(PendingSignIn),
}
/// A sign-in waiting on the viewer.
pub struct PendingSignIn {
secrets: Arc<dyn SecretStore>,
session: ProtonApiSession,
password: String,
}
impl PendingSignIn {
/// Submit a second-factor code.
///
/// A wrong code fails here and leaves nothing behind; the front end starts
/// the sign-in over, because Proton does not accept a second try on the
/// same half-made session.
pub async fn second_factor(mut self, code: &str) -> Result<SignIn> {
self.session
.apply_second_factor_code(code.trim())
.await
.map_err(|error| match error {
ProtonError::Api(api) if (400..500).contains(&api.http_status) => {
Error::SignIn("that code was not accepted".to_owned())
}
other => other.into(),
})?;
self.after_second_factor().await
}
/// Submit the mailbox password, on an account that has one.
///
/// By reference, unlike the code: a mistyped mailbox password is checked
/// on this side, the session survives it, and the viewer can try again
/// without starting over.
pub async fn mailbox_password(&self, mailbox_password: &str) -> Result<Account> {
finish(
Arc::clone(&self.secrets),
self.session.clone(),
mailbox_password,
)
.await
}
async fn after_second_factor(self) -> Result<SignIn> {
if self.session.password_mode() == PasswordMode::Dual {
return Ok(SignIn::MailboxPassword(self));
}
let password = self.password.clone();
finish(self.secrets, self.session, &password)
.await
.map(SignIn::Done)
}
}
/// The signed-in account, if any, and signing in and out.
#[derive(Clone)]
pub struct AccountStore {
secrets: Arc<dyn SecretStore>,
}
impl AccountStore {
pub fn new(secrets: Arc<dyn SecretStore>) -> Self {
Self { secrets }
}
/// The address of the stored account, without touching the network.
pub fn username(&self) -> Result<Option<String>> {
Ok(self.load()?.map(|stored| stored.username))
}
/// Start signing in.
///
/// `verification` is the token from a solved CAPTCHA, after a first try
/// failed with [`Error::HumanVerification`]; the sign-in starts over with
/// it, because the gated attempt's SRP handshake is spent.
pub async fn sign_in(
&self,
username: &str,
password: &str,
verification: Option<HumanVerificationCredential>,
) -> Result<SignIn> {
let session = ProtonApiSession::begin_verified(
client_configuration(),
username.trim(),
password.as_bytes(),
verification.as_ref(),
)
.await
.map_err(classify_sign_in_failure)?;
let pending = PendingSignIn {
secrets: Arc::clone(&self.secrets),
session,
password: password.to_owned(),
};
if pending.session.is_waiting_for_second_factor() {
return Ok(SignIn::SecondFactor(pending));
}
pending.after_second_factor().await
}
/// The stored account, resumed, or `None` when nobody is signed in.
///
/// Makes no request: a session whose refresh token has died is found out
/// by the first call that uses it, as [`Error::is_signed_out`].
pub fn resume(&self) -> Result<Option<Account>> {
let Some(stored) = self.load()? else {
return Ok(None);
};
let session = ProtonApiSession::resume(client_configuration(), stored.resume_parameters())?;
persist_refreshed_tokens(
&self.secrets,
&session,
stored.mailbox_password.clone(),
stored.key_salts.clone(),
);
let drive = ProtonDriveClient::with_key_salts(
&session,
stored.mailbox_password.into_bytes(),
stored.key_salts,
);
Ok(Some(account(session, drive)))
}
/// Forget the account: end its session and delete what was stored.
///
/// The stored session goes even when ending it fails — signing out on a
/// plane must still sign out.
pub async fn sign_out(&self, account: Option<&Account>) -> Result<()> {
if let Some(account) = account {
account.end().await;
}
self.secrets.delete(SESSION_KEY)
}
fn load(&self) -> Result<Option<StoredSession>> {
let Some(encoded) = self.secrets.get(SESSION_KEY)? else {
return Ok(None);
};
serde_json::from_str(&encoded)
.map(Some)
.map_err(|e| Error::Config(format!("the stored Proton session is unreadable: {e}")))
}
}
/// Unlock the key chain with `mailbox_password`, store the session, and hand
/// back the account.
///
/// The key salts are read here, while the token still has the scope that
/// endpoint needs. Loading the addresses is what proves the mailbox password:
/// it unlocks the user keys, and a wrong password unlocks none of them.
async fn finish(
secrets: Arc<dyn SecretStore>,
session: ProtonApiSession,
mailbox_password: &str,
) -> Result<Account> {
let probe = ProtonDriveClient::new(&session, mailbox_password.as_bytes().to_vec());
let key_salts = probe.account().key_salts().await?;
probe
.account()
.addresses()
.await
.map_err(|error| match error {
ProtonError::InvalidOperation(_) => {
Error::SignIn("that mailbox password did not unlock the account".to_owned())
}
other => other.into(),
})?;
let stored = StoredSession::capture(
&session,
session.current_tokens().await,
mailbox_password,
key_salts.clone(),
);
let encoded = serde_json::to_string(&stored)
.map_err(|e| Error::Config(format!("serialize the Proton session: {e}")))?;
secrets.set(SESSION_KEY, &encoded)?;
persist_refreshed_tokens(
&secrets,
&session,
mailbox_password.to_owned(),
key_salts.clone(),
);
let drive = ProtonDriveClient::with_key_salts(
&session,
mailbox_password.as_bytes().to_vec(),
key_salts,
);
Ok(account(session, drive))
}
fn account(session: ProtonApiSession, drive: ProtonDriveClient) -> Account {
let drive = drive
.with_max_inflight_blocks(MAX_INFLIGHT_BLOCKS)
// A watch-history file is one small block; the single-request upload
// is one failure point where the draft dance is three.
.with_small_file_upload(true);
Account { session, drive }
}
/// Write every refreshed token pair back to the secret store.
///
/// Refresh tokens are single-use: once the HTTP client has spent the stored
/// one, only the pair it got back can resume the session.
fn persist_refreshed_tokens(
secrets: &Arc<dyn SecretStore>,
session: &ProtonApiSession,
mailbox_password: String,
key_salts: Vec<KeySalt>,
) {
let secrets = Arc::clone(secrets);
let template = session.clone();
session.http().set_on_tokens_refreshed(move |tokens| {
let stored =
StoredSession::capture(&template, tokens, &mailbox_password, key_salts.clone());
let written = serde_json::to_string(&stored)
.map_err(|e| Error::Config(e.to_string()))
.and_then(|encoded| secrets.set(SESSION_KEY, &encoded));
if let Err(error) = written {
tracing::warn!(%error, "the refreshed Proton session could not be stored");
}
});
}
/// Lift a CAPTCHA gate and a wrong password out of the generic API error.
///
/// Only a gate that can be solved in an app becomes
/// [`Error::HumanVerification`]; one that wants an e-mail or SMS code is a plain
/// failure, which reads better than an empty verification page.
fn classify_sign_in_failure(error: ProtonError) -> Error {
if let ProtonError::Api(api) = &error {
if let Some(challenge) = api.human_verification()
&& challenge.supports_captcha()
{
return Error::HumanVerification(Box::new(challenge));
}
if api.http_status == 422 && !api.is_human_verification_required() {
return Error::SignIn(if api.message.is_empty() {
"Proton did not accept that address and password".to_owned()
} else {
api.message.clone()
});
}
}
error.into()
}
/// The token out of a message posted by Proton's verification page, or `None`
/// for anything that is not a completion.
///
/// The page posts plenty besides — resize requests, readiness pings — and
/// taking an unrecognised message for success would retry the sign-in with a
/// garbage token. Both spellings of the success type have shipped, and some
/// hosts hand the message over stringified twice.
pub fn verification_token(raw: &str) -> Option<String> {
let mut value: serde_json::Value = serde_json::from_str(raw).ok()?;
if let Some(inner) = value.as_str()
&& let Ok(parsed) = serde_json::from_str(inner)
{
value = parsed;
}
let kind = value.get("type")?.as_str()?;
if !matches!(
kind,
"HUMAN_VERIFICATION_SUCCESS" | "human_verification_success"
) {
return None;
}
let token = value.get("payload")?.get("token")?.as_str()?;
(!token.is_empty()).then(|| token.to_owned())
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use parking_lot::Mutex;
use super::*;
#[derive(Default)]
struct MemorySecrets(Mutex<HashMap<String, String>>);
impl SecretStore for MemorySecrets {
fn set(&self, key: &str, value: &str) -> Result<()> {
self.0.lock().insert(key.to_owned(), value.to_owned());
Ok(())
}
fn get(&self, key: &str) -> Result<Option<String>> {
Ok(self.0.lock().get(key).cloned())
}
fn delete(&self, key: &str) -> Result<()> {
self.0.lock().remove(key);
Ok(())
}
}
fn stored() -> StoredSession {
StoredSession {
session_id: "session".to_owned(),
username: "viewer@proton.me".to_owned(),
user_id: "user".to_owned(),
access_token: "access".to_owned(),
refresh_token: "refresh".to_owned(),
scopes: vec!["drive".to_owned()],
password_mode: 2,
mailbox_password: "mailbox".to_owned(),
key_salts: Vec::new(),
}
}
#[test]
fn nobody_is_signed_in_until_a_session_is_stored() {
let store = AccountStore::new(Arc::new(MemorySecrets::default()));
assert_eq!(store.username().expect("read"), None);
assert!(store.resume().expect("resume").is_none());
}
#[test]
fn a_stored_session_resumes_as_its_account_without_a_request() {
let secrets = Arc::new(MemorySecrets::default());
secrets
.set(SESSION_KEY, &serde_json::to_string(&stored()).unwrap())
.unwrap();
let store = AccountStore::new(secrets);
assert_eq!(
store.username().expect("read").as_deref(),
Some("viewer@proton.me")
);
let account = store.resume().expect("resume").expect("signed in");
assert_eq!(account.username(), "viewer@proton.me");
}
#[tokio::test]
async fn signing_out_forgets_the_stored_session() {
let secrets = Arc::new(MemorySecrets::default());
secrets
.set(SESSION_KEY, &serde_json::to_string(&stored()).unwrap())
.unwrap();
let store = AccountStore::new(secrets);
store.sign_out(None).await.expect("sign out");
assert_eq!(store.username().expect("read"), None);
}
#[test]
fn a_stored_session_keeps_its_password_mode() {
let params = stored().resume_parameters();
assert_eq!(params.password_mode, PasswordMode::Dual);
assert!(!params.is_waiting_for_second_factor_code);
}
#[test]
fn a_verification_completion_yields_its_token() {
let raw =
r#"{"type":"HUMAN_VERIFICATION_SUCCESS","payload":{"token":"tok-1","type":"captcha"}}"#;
assert_eq!(verification_token(raw).as_deref(), Some("tok-1"));
let twice = r#""{\"type\":\"HUMAN_VERIFICATION_SUCCESS\",\"payload\":{\"token\":\"tok-1\",\"type\":\"captcha\"}}""#;
assert_eq!(verification_token(twice).as_deref(), Some("tok-1"));
}
#[test]
fn messages_that_are_not_a_verification_completion_are_ignored() {
for raw in [
r#"{"type":"resize","payload":{"height":400}}"#,
r#"{"type":"HUMAN_VERIFICATION_SUCCESS"}"#,
r#"{"type":"HUMAN_VERIFICATION_SUCCESS","payload":{"token":""}}"#,
"not json",
"",
] {
assert!(verification_token(raw).is_none(), "accepted: {raw}");
}
}
}
+103
View File
@@ -721,6 +721,46 @@ impl Catalog {
Ok(()) Ok(())
} }
/// Take each of `states` that is newer than what is recorded, in one
/// transaction, returning how many were taken.
///
/// Newer is a later `updated_at`. A tie is broken by the state itself —
/// watched over not, then the further position — rather than by who is
/// asking, so two devices holding the same tie settle on the same answer
/// instead of each keeping its own forever. See [`crate::sync`].
pub fn merge_watch_states(&mut self, states: &[(String, String, WatchState)]) -> Result<usize> {
let tx = self.conn.transaction()?;
let mut taken = 0;
{
let mut upsert = tx.prepare_cached(
"INSERT INTO watch_state (share_id, link_id, position_secs, duration_secs, watched, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT (share_id, link_id) DO UPDATE SET
position_secs = excluded.position_secs,
duration_secs = excluded.duration_secs,
watched = excluded.watched,
updated_at = excluded.updated_at
WHERE excluded.updated_at > watch_state.updated_at
OR (excluded.updated_at = watch_state.updated_at
AND (excluded.watched > watch_state.watched
OR (excluded.watched = watch_state.watched
AND excluded.position_secs > watch_state.position_secs)))",
)?;
for (share_id, link_id, state) in states {
taken += upsert.execute(params![
share_id,
link_id,
state.position_secs,
state.duration_secs,
state.watched as i64,
state.updated_at,
])?;
}
}
tx.commit()?;
Ok(taken)
}
/// Where playback left off, if it ever started. /// Where playback left off, if it ever started.
pub fn watch_state(&self, share_id: &str, link_id: &str) -> Result<Option<WatchState>> { pub fn watch_state(&self, share_id: &str, link_id: &str) -> Result<Option<WatchState>> {
let state = self let state = self
@@ -2390,6 +2430,69 @@ mod tests {
assert_eq!(rows[0].link_id, "film"); assert_eq!(rows[0].link_id, "film");
} }
#[test]
fn merging_takes_only_states_newer_than_the_recorded_ones() {
let mut catalog = Catalog::in_memory().expect("open");
let at = |position_secs: f64, updated_at: i64| WatchState {
position_secs,
duration_secs: Some(1440.0),
watched: false,
updated_at,
};
catalog
.set_watch_state("s1", "kept", &at(100.0, 50))
.unwrap();
catalog
.set_watch_state("s1", "taken", &at(100.0, 50))
.unwrap();
let taken = catalog
.merge_watch_states(&[
("s1".to_owned(), "kept".to_owned(), at(900.0, 40)),
("s1".to_owned(), "taken".to_owned(), at(900.0, 60)),
("s1".to_owned(), "new".to_owned(), at(5.0, 10)),
])
.expect("merge");
assert_eq!(taken, 2);
assert_eq!(
catalog.watch_state("s1", "kept").unwrap(),
Some(at(100.0, 50))
);
assert_eq!(
catalog.watch_state("s1", "taken").unwrap(),
Some(at(900.0, 60))
);
assert_eq!(catalog.watch_state("s1", "new").unwrap(), Some(at(5.0, 10)));
}
#[test]
fn a_merge_tie_settles_the_same_way_whichever_side_holds_it() {
let state = |position_secs: f64, watched: bool| WatchState {
position_secs,
duration_secs: Some(1440.0),
watched,
updated_at: 70,
};
for (local, remote) in [
(state(300.0, false), state(1440.0, true)),
(state(1440.0, true), state(300.0, false)),
(state(300.0, false), state(600.0, false)),
(state(600.0, false), state(300.0, false)),
] {
let mut catalog = Catalog::in_memory().expect("open");
catalog.set_watch_state("s1", "a", &local).unwrap();
catalog
.merge_watch_states(&[("s1".to_owned(), "a".to_owned(), remote)])
.unwrap();
let winner =
if (local.watched, local.position_secs) > (remote.watched, remote.position_secs) {
local
} else {
remote
};
assert_eq!(catalog.watch_state("s1", "a").unwrap(), Some(winner));
}
}
#[test] #[test]
fn an_unwatched_file_has_no_watch_state() { fn an_unwatched_file_has_no_watch_state() {
let catalog = Catalog::in_memory().expect("open"); let catalog = Catalog::in_memory().expect("open");
+6
View File
@@ -88,6 +88,12 @@ impl AppDirs {
self.config.join("shares.json") self.config.join("shares.json")
} }
/// This installation's identity for watch-history sync. See
/// [`crate::sync`].
pub fn sync_file(&self) -> PathBuf {
self.config.join("sync.json")
}
/// The catalog database. /// The catalog database.
pub fn catalog_db(&self) -> PathBuf { pub fn catalog_db(&self) -> PathBuf {
self.data.join("catalog.db") self.data.join("catalog.db")
+31
View File
@@ -30,6 +30,37 @@ pub enum Error {
/// The caller asked for something that does not exist or does not apply. /// The caller asked for something that does not exist or does not apply.
#[error("{0}")] #[error("{0}")]
NotFound(String), NotFound(String),
/// Proton gated a sign-in behind a CAPTCHA. The front end presents
/// [`HumanVerification::verification_url`](proton_sdk::api::HumanVerification::verification_url)
/// and signs in again with the token it yields.
#[error("Proton wants to confirm you are human before signing in")]
HumanVerification(Box<proton_sdk::api::HumanVerification>),
/// No account is signed in, or its session can no longer be refreshed and
/// the viewer has to sign in again.
#[error("{0}")]
SignedOut(String),
/// Signing in failed for a reason the viewer can act on, such as a wrong
/// mailbox password.
#[error("{0}")]
SignIn(String),
}
impl Error {
/// Whether this is the account's session having ended: the refresh token
/// was revoked or expired, or the token lacks a scope only a password
/// sign-in grants. Nothing but signing in again fixes it.
pub fn is_signed_out(&self) -> bool {
match self {
Self::SignedOut(_) => true,
Self::Drive(proton_sdk::error::ProtonError::Api(api)) => {
api.is_invalid_refresh_token() || api.is_insufficient_scope()
}
_ => false,
}
}
} }
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
+4 -1
View File
@@ -5,6 +5,7 @@
//! how a share is opened and how its contents are modelled. //! how a share is opened and how its contents are modelled.
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
pub mod account;
pub mod appearance; pub mod appearance;
pub mod browse; pub mod browse;
pub mod catalog; pub mod catalog;
@@ -18,11 +19,13 @@ pub mod naming;
pub mod prefs; pub mod prefs;
pub mod shares; pub mod shares;
pub mod shelves; pub mod shelves;
pub mod sync;
pub use account::{Account, AccountStore};
pub use error::{Error, Result}; pub use error::{Error, Result};
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
pub use shares::KeyringSecretStore; pub use shares::KeyringSecretStore;
pub use shares::{SecretStore, Share, ShareStore, SharedLibrary}; pub use shares::{SecretStore, Share, ShareClient, ShareStore, SharedLibrary};
pub use proton_drive_rs; pub use proton_drive_rs;
pub use proton_sdk; pub use proton_sdk;
+213 -18
View File
@@ -14,15 +14,28 @@
//! A library is not one folder. [`SharedLibrary`] opens every configured share //! A library is not one folder. [`SharedLibrary`] opens every configured share
//! and merges them into one catalog, so the app has a single browsable view //! and merges them into one catalog, so the app has a single browsable view
//! regardless of how many links it was given. //! regardless of how many links it was given.
//!
//! ## Folders from a signed-in account
//!
//! A share can also be a folder of the viewer's own Drive — My Files, another
//! device, or something shared with them — opened through the account signed
//! in by [`crate::account`] instead of a link. It has no secrets of its own:
//! the account session is the credential, and without it such a share simply
//! fails to open, the same as a revoked link. [`ShareClient`] puts both kinds
//! behind one surface so the crawl, the stream layer and the thumbnail path
//! do not care which one they hold.
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::sync::Arc; use std::sync::Arc;
use proton_drive_rs::{Node, ProtonDrivePublicLinkClient}; use proton_drive_rs::{
Node, ProtonDriveClient, ProtonDrivePublicLinkClient, RevisionReader, ThumbnailType,
};
use proton_sdk::config::ProtonClientConfiguration; use proton_sdk::config::ProtonClientConfiguration;
use proton_sdk::ids::NodeUid; use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::account::Account;
use crate::config::{AppDirs, read_json, write_json}; use crate::config::{AppDirs, read_json, write_json};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
@@ -92,6 +105,47 @@ pub struct Share {
/// rather than after it fails. /// rather than after it fails.
#[serde(default)] #[serde(default)]
pub has_custom_password: bool, pub has_custom_password: bool,
/// Set when this share is a folder of the signed-in account rather than a
/// public link; `token` is then empty and there are no secrets.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub folder: Option<AccountFolder>,
}
/// A folder of the signed-in account, kept as a library share.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccountFolder {
pub volume_id: String,
pub link_id: String,
}
impl AccountFolder {
pub fn uid(&self) -> NodeUid {
NodeUid::new(
VolumeId::from(self.volume_id.clone()),
LinkId::from(self.link_id.clone()),
)
}
/// The share id this folder is kept under.
///
/// Derived, like a link's `share-{token}`, so the same folder added on two
/// devices is the same share — which is what lets watch history recorded
/// against it on one device land on the other. Hashed rather than spliced
/// in because Proton ids are base64 and may carry characters a catalog key
/// is better off without.
pub fn share_id(&self) -> String {
use sha2::{Digest, Sha256};
let mut hash = Sha256::new();
hash.update(self.volume_id.as_bytes());
hash.update([0]);
hash.update(self.link_id.as_bytes());
let digest = hash.finalize();
let hex: String = digest[..12]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
format!("drive-{hex}")
}
} }
/// The secrets for one share, as held in the credential store. /// The secrets for one share, as held in the credential store.
@@ -154,6 +208,31 @@ impl ShareStore {
name: name.to_string(), name: name.to_string(),
token, token,
has_custom_password: secrets.custom_password.is_some(), has_custom_password: secrets.custom_password.is_some(),
folder: None,
};
shares.push(share.clone());
write_json(&self.dirs.shares_file(), &shares)?;
Ok(share)
}
/// Record a folder of the signed-in account as a share.
///
/// Nothing goes in the credential store: the folder opens through the
/// account's session, which [`crate::account::AccountStore`] keeps.
pub fn add_folder(&self, name: &str, folder: AccountFolder) -> Result<Share> {
let id = folder.share_id();
let mut shares = self.list()?;
if shares.iter().any(|share| share.id == id) {
return Err(Error::Config(
"that folder is already in the library".to_owned(),
));
}
let share = Share {
id,
name: name.to_string(),
token: String::new(),
has_custom_password: false,
folder: Some(folder),
}; };
shares.push(share.clone()); shares.push(share.clone());
write_json(&self.dirs.shares_file(), &shares)?; write_json(&self.dirs.shares_file(), &shares)?;
@@ -184,6 +263,11 @@ impl ShareStore {
.iter_mut() .iter_mut()
.find(|share| share.id == id) .find(|share| share.id == id)
.ok_or_else(|| Error::NotFound(format!("no share with id {id}")))?; .ok_or_else(|| Error::NotFound(format!("no share with id {id}")))?;
if share.folder.is_some() {
return Err(Error::Config(
"that share is a folder of your account and has no link".to_owned(),
));
}
if share.token != token { if share.token != token {
return Err(Error::Config( return Err(Error::Config(
"that link is for a different share; add it as a new one".to_owned(), "that link is for a different share; add it as a new one".to_owned(),
@@ -221,8 +305,20 @@ impl ShareStore {
self.secrets.delete(id) self.secrets.delete(id)
} }
/// Open a share as a visitor. /// Open a share: a link as a visitor, a folder through `account`.
pub async fn open(&self, share: &Share) -> Result<ProtonDrivePublicLinkClient> { pub async fn open(&self, share: &Share, account: Option<&Account>) -> Result<ShareClient> {
if let Some(folder) = &share.folder {
let account = account.ok_or_else(|| {
Error::SignedOut(format!(
"sign in to Proton to open {}, a folder of your account",
share.name
))
})?;
return Ok(ShareClient::Account {
client: account.drive().clone(),
root: folder.uid(),
});
}
let secrets = self.load_secrets(&share.id)?; let secrets = self.load_secrets(&share.id)?;
let client = ProtonDrivePublicLinkClient::open( let client = ProtonDrivePublicLinkClient::open(
client_configuration(), client_configuration(),
@@ -234,7 +330,7 @@ impl ShareStore {
// seeks wants more blocks in flight; at 4 MiB each this is a 192 MiB // seeks wants more blocks in flight; at 4 MiB each this is a 192 MiB
// ceiling, which is unremarkable for a desktop app. // ceiling, which is unremarkable for a desktop app.
.with_max_inflight_blocks(48); .with_max_inflight_blocks(48);
Ok(client) Ok(ShareClient::PublicLink(Box::new(client)))
} }
fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> { fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> {
@@ -256,7 +352,96 @@ impl ShareStore {
/// Several opened shares, presented as one library. /// Several opened shares, presented as one library.
pub struct SharedLibrary { pub struct SharedLibrary {
clients: BTreeMap<String, ProtonDrivePublicLinkClient>, clients: BTreeMap<String, ShareClient>,
}
/// One opened share, whichever kind it is.
///
/// Cheap to clone: both clients share their caches and sessions between
/// clones.
#[derive(Clone)]
pub enum ShareClient {
/// A public link, opened as a visitor. Boxed: the visitor client is
/// several times the size of the account variant, and libraries are
/// mostly links, so every map entry would otherwise pay for it.
PublicLink(Box<ProtonDrivePublicLinkClient>),
/// A folder of the signed-in account.
Account {
client: ProtonDriveClient,
root: NodeUid,
},
}
impl ShareClient {
/// The share's top folder.
pub async fn root_node(&self) -> proton_sdk::error::Result<Node> {
match self {
Self::PublicLink(client) => client.get_root_node().await,
Self::Account { client, root } => client.get_node(root).await?.ok_or_else(|| {
proton_sdk::error::ProtonError::invalid_operation(
"the folder is no longer in your Drive",
)
}),
}
}
pub async fn enumerate_folder_children_node_uids(
&self,
folder: &NodeUid,
) -> proton_sdk::error::Result<Vec<NodeUid>> {
match self {
Self::PublicLink(client) => client.enumerate_folder_children_node_uids(folder).await,
Self::Account { client, .. } => {
client.enumerate_folder_children_node_uids(folder).await
}
}
}
pub async fn enumerate_nodes(&self, uids: &[NodeUid]) -> proton_sdk::error::Result<Vec<Node>> {
match self {
Self::PublicLink(client) => client.enumerate_nodes(uids).await,
Self::Account { client, .. } => client.enumerate_nodes(uids).await,
}
}
pub async fn open_revision(&self, uid: &NodeUid) -> proton_sdk::error::Result<RevisionReader> {
match self {
Self::PublicLink(client) => client.open_revision(uid).await,
Self::Account { client, .. } => client.open_revision(uid).await,
}
}
pub async fn download_range(
&self,
uid: &NodeUid,
offset: u64,
length: u64,
) -> proton_sdk::error::Result<Vec<u8>> {
match self {
Self::PublicLink(client) => client.download_range(uid, offset, length).await,
Self::Account { client, .. } => client.download_range(uid, offset, length).await,
}
}
pub async fn download_thumbnail(
&self,
uid: &NodeUid,
kind: ThumbnailType,
) -> proton_sdk::error::Result<Option<Vec<u8>>> {
match self {
Self::PublicLink(client) => client.download_thumbnail(uid, kind).await,
Self::Account { client, .. } => client.download_thumbnail(uid, kind).await,
}
}
/// Replay a visitor handshake. An account session refreshes its own
/// tokens when one expires, so there is nothing to do for a folder.
pub async fn refresh_session(&self) -> proton_sdk::error::Result<()> {
match self {
Self::PublicLink(client) => client.refresh_session().await,
Self::Account { .. } => Ok(()),
}
}
} }
impl SharedLibrary { impl SharedLibrary {
@@ -265,8 +450,14 @@ impl SharedLibrary {
/// A share that fails to open is reported and skipped rather than failing /// A share that fails to open is reported and skipped rather than failing
/// the whole library — one revoked link should not make the other three /// the whole library — one revoked link should not make the other three
/// unwatchable. /// unwatchable.
pub async fn open_all(store: &ShareStore) -> Result<(Self, Vec<(Share, Error)>)> { ///
Self::open_all_reusing(store, BTreeMap::new()).await /// Folders of the account open through `account`; without one they are
/// reported as failures like any other share that will not open.
pub async fn open_all(
store: &ShareStore,
account: Option<&Account>,
) -> Result<(Self, Vec<(Share, Error)>)> {
Self::open_all_reusing(store, BTreeMap::new(), account).await
} }
/// Open every configured share, reusing the clients in `reusable`. /// Open every configured share, reusing the clients in `reusable`.
@@ -278,7 +469,8 @@ impl SharedLibrary {
/// link. /// link.
pub async fn open_all_reusing( pub async fn open_all_reusing(
store: &ShareStore, store: &ShareStore,
mut reusable: BTreeMap<String, ProtonDrivePublicLinkClient>, mut reusable: BTreeMap<String, ShareClient>,
account: Option<&Account>,
) -> Result<(Self, Vec<(Share, Error)>)> { ) -> Result<(Self, Vec<(Share, Error)>)> {
let mut clients = BTreeMap::new(); let mut clients = BTreeMap::new();
let mut failures = Vec::new(); let mut failures = Vec::new();
@@ -288,7 +480,7 @@ impl SharedLibrary {
clients.insert(share.id, client); clients.insert(share.id, client);
continue; continue;
} }
match store.open(&share).await { match store.open(&share, account).await {
Ok(client) => { Ok(client) => {
clients.insert(share.id.clone(), client); clients.insert(share.id.clone(), client);
} }
@@ -305,19 +497,22 @@ impl SharedLibrary {
/// The excluded share is the one whose credentials or membership just /// The excluded share is the one whose credentials or membership just
/// changed; leaving it out is what makes a removed share's live client /// changed; leaving it out is what makes a removed share's live client
/// unreachable rather than merely stale. /// unreachable rather than merely stale.
pub fn reusable_clients( ///
&self, /// Folders of the account are never handed back: they carry the session
dropping: &str, /// they were opened with, and the account may have signed out or changed
) -> BTreeMap<String, ProtonDrivePublicLinkClient> { /// since.
pub fn reusable_clients(&self, dropping: &str) -> BTreeMap<String, ShareClient> {
self.clients self.clients
.iter() .iter()
.filter(|(id, _)| id.as_str() != dropping) .filter(|(id, client)| {
id.as_str() != dropping && matches!(client, ShareClient::PublicLink(_))
})
.map(|(id, client)| (id.clone(), client.clone())) .map(|(id, client)| (id.clone(), client.clone()))
.collect() .collect()
} }
/// The client for one share. /// The client for one share.
pub fn client(&self, share_id: &str) -> Option<&ProtonDrivePublicLinkClient> { pub fn client(&self, share_id: &str) -> Option<&ShareClient> {
self.clients.get(share_id) self.clients.get(share_id)
} }
@@ -377,7 +572,7 @@ impl SharedLibrary {
.client(share_id) .client(share_id)
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?; .ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
let root = client.get_root_node().await?; let root = client.root_node().await?;
let mut level = vec![root.uid.clone()]; let mut level = vec![root.uid.clone()];
let mut found = vec![root]; let mut found = vec![root];
@@ -433,7 +628,7 @@ const APP_VERSION: &str = concat!("external-drive-stream@", env!("CARGO_PKG_VERS
const USER_AGENT: &str = concat!("proton-stream/", env!("CARGO_PKG_VERSION")); const USER_AGENT: &str = concat!("proton-stream/", env!("CARGO_PKG_VERSION"));
/// The API configuration every visitor client is built from. /// The API configuration every visitor client is built from.
fn client_configuration() -> ProtonClientConfiguration { pub(crate) fn client_configuration() -> ProtonClientConfiguration {
ProtonClientConfiguration::new(APP_VERSION).with_user_agent(USER_AGENT) ProtonClientConfiguration::new(APP_VERSION).with_user_agent(USER_AGENT)
} }
+457
View File
@@ -0,0 +1,457 @@
//! Watch history kept in the viewer's own Drive, so every device resumes where
//! another left off.
//!
//! ## Layout
//!
//! The history lives on a Drive *device* of its own, named [`DEVICE_NAME`]: the
//! same kind of entry a desktop sync client registers, so it shows up in the
//! web app under Computers rather than as a stray folder in My Files. Inside
//! it, `watch-history/` holds one JSON file per installation of this app,
//! named by an id generated once and kept in [`AppDirs::sync_file`].
//!
//! ## Why a file per installation
//!
//! Drive has no merge: two devices writing one file is last writer wins for
//! the *whole file*, and the loser's positions are gone. With a file each,
//! every installation only ever writes its own and reads everyone else's, so
//! nothing is overwritten and there is no write conflict to resolve.
//!
//! Each file carries the installation's whole watch table, and merging is per
//! episode: the newer `updated_at` wins. That is idempotent and order-free, so
//! it does not matter how many devices there are, which synced first, or that
//! a device writes back entries it learned from another. A device whose clock
//! runs ahead wins ties it should not, which is the price of not having a
//! server.
//!
//! The keys travel: `share-{token}` is derived from the link and a folder's
//! `drive-…` id from its node, so the same library added on two devices has
//! the same `(share_id, link_id)` on both. Nothing secret is in a file — the
//! link token decrypts nothing without the fragment — and the file is
//! end-to-end encrypted by Drive regardless.
use std::collections::HashMap;
use parking_lot::Mutex;
use proton_drive_rs::{DeviceType, NodeKind};
use proton_sdk::ids::NodeUid;
use serde::{Deserialize, Serialize};
use crate::account::Account;
use crate::catalog::{Catalog, WatchState};
use crate::config::{AppDirs, read_json, write_json};
use crate::error::{Error, Result};
/// The Drive device watch history is kept on.
pub const DEVICE_NAME: &str = "proton-stream";
/// The folder on that device holding one file per installation.
const FOLDER: &str = "watch-history";
/// Bumped only for a change an older build would misread. A file of a newer
/// version is skipped rather than half-understood.
const FORMAT_VERSION: u32 = 1;
/// One installation's watch history, as stored in Drive.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct HistoryFile {
version: u32,
installation: String,
entries: Vec<HistoryEntry>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct HistoryEntry {
share: String,
link: String,
position: f64,
#[serde(default)]
duration: Option<f64>,
watched: bool,
updated_at: i64,
}
impl HistoryEntry {
fn from_state((share, link): (String, String), state: WatchState) -> Self {
Self {
share,
link,
position: state.position_secs,
duration: state.duration_secs,
watched: state.watched,
updated_at: state.updated_at,
}
}
fn state(&self) -> WatchState {
WatchState {
position_secs: self.position,
duration_secs: self.duration,
watched: self.watched,
updated_at: self.updated_at,
}
}
/// Whether this entry could have come from this app. A file is another
/// device's to write, so its contents are checked before they reach the
/// catalog.
fn is_sane(&self) -> bool {
self.position.is_finite()
&& self.position >= 0.0
&& self
.duration
.is_none_or(|duration| duration.is_finite() && duration >= 0.0)
&& !self.share.is_empty()
&& !self.link.is_empty()
}
}
/// This installation's sync identity, kept beside the share list.
#[derive(Debug, Serialize, Deserialize)]
struct Identity {
installation: String,
}
/// What one [`WatchSync::sync`] did.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SyncReport {
/// Episodes whose position another device had newer.
pub applied: usize,
/// Whether this installation's file was written.
pub uploaded: bool,
/// Other installations whose history was read.
pub devices: usize,
}
/// A history file as last read, by the revision it was read at.
struct Seen {
revision: Option<String>,
entries: Vec<HistoryEntry>,
}
/// Syncs the catalog's watch table with the account's Drive.
pub struct WatchSync {
installation: String,
/// Remote files by link id. A file whose active revision has not moved
/// since it was read is not downloaded again.
seen: Mutex<HashMap<String, Seen>>,
/// What this installation's own file holds, once known: what was read
/// back at the first sync, then what was last written. Compared against
/// the catalog so an unchanged history is not uploaded as a new revision.
own: Mutex<Option<Vec<HistoryEntry>>>,
/// The history folders, resolved once per account. Usually one; see
/// [`WatchSync::folders`].
folders: Mutex<Option<Vec<NodeUid>>>,
/// One sync at a time: two would race on creating the device and the
/// installation's file.
running: tokio::sync::Mutex<()>,
}
impl WatchSync {
/// This installation's sync, with its id read from — or, the first time,
/// written to — [`AppDirs::sync_file`].
pub fn open(dirs: &AppDirs) -> Result<Self> {
let path = dirs.sync_file();
let installation = match read_json::<Identity>(&path)? {
Some(identity) => identity.installation,
None => {
let identity = Identity {
installation: new_installation_id()?,
};
write_json(&path, &identity)?;
identity.installation
}
};
Ok(Self::with_installation(installation))
}
fn with_installation(installation: String) -> Self {
Self {
installation,
seen: Mutex::new(HashMap::new()),
own: Mutex::new(None),
folders: Mutex::new(None),
running: tokio::sync::Mutex::new(()),
}
}
/// Forget everything learned about the account, after signing out or in
/// as someone else.
pub fn reset(&self) {
self.seen.lock().clear();
*self.own.lock() = None;
*self.folders.lock() = None;
}
/// Pull every other installation's history into `catalog`, then write
/// this one's if it changed.
///
/// The catalog is locked only around its own reads and writes, never
/// across a request, so playback can keep saving its position while this
/// runs.
pub async fn sync(&self, account: &Account, catalog: &Mutex<Catalog>) -> Result<SyncReport> {
let _running = self.running.lock().await;
let drive = account.drive();
let folders = self.folders(account).await?;
let own_name = format!("{}.json", self.installation);
let mut own_file = None;
let mut remote = Vec::new();
let mut devices = 0;
for folder in &folders {
let uids = drive.enumerate_folder_children_node_uids(folder).await?;
if uids.is_empty() {
continue;
}
for node in drive.enumerate_nodes(&uids).await? {
let NodeKind::File {
active_revision_id, ..
} = &node.kind
else {
continue;
};
if node.trashed || !node.name.ends_with(".json") {
continue;
}
if node.name == own_name {
if own_file.is_none() {
own_file = Some((node.uid.clone(), active_revision_id.clone()));
}
continue;
}
devices += 1;
remote.extend(
self.read(account, &node.uid, active_revision_id.clone())
.await,
);
}
}
if self.own.lock().is_none() {
let entries = match &own_file {
Some((uid, revision)) => self.read(account, uid, revision.clone()).await,
None => Vec::new(),
};
// What this installation wrote before is history too: after a
// reinstall that kept the config, or a catalog that was deleted.
remote.extend(entries.iter().cloned());
*self.own.lock() = Some(entries);
}
let incoming: Vec<(String, String, WatchState)> = remote
.iter()
.filter(|entry| entry.is_sane())
.map(|entry| (entry.share.clone(), entry.link.clone(), entry.state()))
.collect();
let (applied, local) = {
let mut catalog = catalog.lock();
let applied = catalog.merge_watch_states(&incoming)?;
(applied, catalog.all_watch_states()?)
};
let mut entries: Vec<HistoryEntry> = local
.into_iter()
.map(|(key, state)| HistoryEntry::from_state(key, state))
.collect();
entries.sort_by(|a, b| (&a.share, &a.link).cmp(&(&b.share, &b.link)));
let unchanged = self.own.lock().as_ref() == Some(&entries);
let uploaded = if unchanged || (entries.is_empty() && own_file.is_none()) {
false
} else {
let file = HistoryFile {
version: FORMAT_VERSION,
installation: self.installation.clone(),
entries: entries.clone(),
};
let bytes = serde_json::to_vec(&file)
.map_err(|e| Error::Config(format!("serialize watch history: {e}")))?;
match &own_file {
Some((uid, _)) => drive.upload_new_revision(uid, &bytes).await?,
None => {
drive
.upload_file(&folders[0], &own_name, "application/json", &bytes)
.await?;
}
}
*self.own.lock() = Some(entries);
true
};
Ok(SyncReport {
applied,
uploaded,
devices,
})
}
/// The entries of one history file, from the cache when its revision has
/// not moved.
///
/// A file that will not download or parse is logged and read as empty: it
/// is another device's, and one bad file must not stop the rest syncing.
async fn read(
&self,
account: &Account,
uid: &NodeUid,
revision: Option<String>,
) -> Vec<HistoryEntry> {
let key = uid.link_id.as_str().to_owned();
if let Some(seen) = self.seen.lock().get(&key)
&& revision.is_some()
&& seen.revision == revision
{
return seen.entries.clone();
}
let entries = match account.drive().download_file(uid).await {
Ok(bytes) => match parse(&bytes) {
Some(entries) => entries,
None => {
tracing::warn!(%uid, "skipping a watch-history file this build cannot read");
Vec::new()
}
},
Err(error) => {
tracing::warn!(%uid, %error, "a watch-history file could not be downloaded");
return Vec::new();
}
};
self.seen.lock().insert(
key,
Seen {
revision,
entries: entries.clone(),
},
);
entries
}
/// The history folders, creating the device and folder the first time.
///
/// Two installations signing in at the same moment can each create the
/// device before seeing the other's. Every device of that name is read
/// from so neither's history is lost; the oldest is the one written to,
/// which is the same one on every installation.
async fn folders(&self, account: &Account) -> Result<Vec<NodeUid>> {
if let Some(folders) = self.folders.lock().clone() {
return Ok(folders);
}
let drive = account.drive();
let mut devices: Vec<_> = drive
.enumerate_devices()
.await?
.into_iter()
.filter(|device| device.name.as_deref().ok() == Some(DEVICE_NAME))
.collect();
devices.sort_by_key(|device| device.creation_time);
if devices.is_empty() {
devices.push(drive.create_device(DEVICE_NAME, device_type()).await?);
}
let mut folders = Vec::with_capacity(devices.len());
for device in &devices {
folders.push(
drive
.create_folder_path(&device.root_folder_uid, FOLDER)
.await?,
);
}
*self.folders.lock() = Some(folders.clone());
Ok(folders)
}
}
/// A history file's entries, or `None` for one this build cannot read.
fn parse(bytes: &[u8]) -> Option<Vec<HistoryEntry>> {
let file: HistoryFile = serde_json::from_slice(bytes).ok()?;
(file.version <= FORMAT_VERSION).then_some(file.entries)
}
/// The kind of device to register, which is only the icon the web app draws.
fn device_type() -> DeviceType {
if cfg!(target_os = "windows") {
DeviceType::Windows
} else if cfg!(target_os = "macos") {
DeviceType::MacOs
} else {
DeviceType::Linux
}
}
fn new_installation_id() -> Result<String> {
let mut bytes = [0_u8; 16];
getrandom::fill(&mut bytes)
.map_err(|e| Error::Config(format!("no randomness for an installation id: {e}")))?;
Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(link: &str, position: f64, updated_at: i64) -> HistoryEntry {
HistoryEntry {
share: "share-a".to_owned(),
link: link.to_owned(),
position,
duration: Some(1440.0),
watched: false,
updated_at,
}
}
#[test]
fn an_installation_keeps_the_id_it_was_given() {
let root =
std::env::temp_dir().join(format!("pstr-sync-{}", new_installation_id().unwrap()));
let dirs = AppDirs::from_paths(root.join("c"), root.join("d"), root.join("k")).unwrap();
let first = WatchSync::open(&dirs).unwrap().installation;
let second = WatchSync::open(&dirs).unwrap().installation;
assert_eq!(first, second);
assert_eq!(first.len(), 32);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn a_history_file_round_trips() {
let file = HistoryFile {
version: FORMAT_VERSION,
installation: "abc".to_owned(),
entries: vec![entry("a", 12.5, 100)],
};
let bytes = serde_json::to_vec(&file).unwrap();
assert_eq!(parse(&bytes), Some(file.entries));
}
#[test]
fn a_history_file_from_a_newer_format_is_not_read() {
let file = HistoryFile {
version: FORMAT_VERSION + 1,
installation: "abc".to_owned(),
entries: vec![entry("a", 12.5, 100)],
};
assert_eq!(parse(&serde_json::to_vec(&file).unwrap()), None);
assert_eq!(parse(b"not json"), None);
}
#[test]
fn entries_another_device_could_not_have_written_are_refused() {
assert!(entry("a", 1.0, 1).is_sane());
assert!(!entry("a", f64::NAN, 1).is_sane());
assert!(!entry("a", -1.0, 1).is_sane());
assert!(!entry("", 1.0, 1).is_sane());
let mut infinite = entry("a", 1.0, 1);
infinite.duration = Some(f64::INFINITY);
assert!(!infinite.is_sane());
}
#[test]
fn an_entry_carries_its_watch_state_unchanged() {
let state = WatchState {
position_secs: 61.0,
duration_secs: None,
watched: true,
updated_at: 42,
};
let entry = HistoryEntry::from_state(("s".to_owned(), "l".to_owned()), state);
assert_eq!(entry.state(), state);
}
}
+16
View File
@@ -421,6 +421,22 @@ impl MetadataService {
} }
} }
/// Pin `title` to the entry the viewer picked, and enrich it.
///
/// Both halves, always: the point of choosing an entry by hand is usually
/// that the episode names were wrong too, so a choice that stored only the
/// title record left the episodes, the backdrop and the franchise chain of
/// the *old* match — or none — until some later match run got round to it.
/// A failed enrichment still keeps the choice, as in [`Self::work`].
pub async fn choose(&self, title: &Title, found: TitleMetadata) -> Outcome {
let enrichment = self.enrich_or_log(title, &found).await;
Outcome {
title_key: title.key.clone(),
record: Some(self.chosen(title.key.clone(), found)),
enrichment,
}
}
async fn enrich_or_log(&self, title: &Title, found: &TitleMetadata) -> Option<Enrichment> { async fn enrich_or_log(&self, title: &Title, found: &TitleMetadata) -> Option<Enrichment> {
self.enrich(title, found) self.enrich(title, found)
.await .await
+1 -1
View File
@@ -42,4 +42,4 @@ pub use proton_sdk::ids::NodeUid;
pub use reader::{FileBlocks, LibraryOpener, RevisionBlocks}; pub use reader::{FileBlocks, LibraryOpener, RevisionBlocks};
pub use ring::{DEFAULT_RING_BYTES, RingStats}; pub use ring::{DEFAULT_RING_BYTES, RingStats};
pub use source::{RevisionOpener, StreamConfig, StreamSource}; pub use source::{RevisionOpener, StreamConfig, StreamSource};
pub use stream::{DEFAULT_READAHEAD_BLOCKS, StreamStats, VideoStream}; pub use stream::{COPY_BLOCKS_IN_FLIGHT, DEFAULT_READAHEAD_BLOCKS, StreamStats, VideoStream};
+1 -2
View File
@@ -13,7 +13,6 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use proton_drive_rs::ProtonDrivePublicLinkClient;
use proton_sdk::error::ProtonError; use proton_sdk::error::ProtonError;
use proton_sdk::ids::NodeUid; use proton_sdk::ids::NodeUid;
@@ -159,7 +158,7 @@ impl LibraryOpener {
Self { library } Self { library }
} }
fn client(&self, share_id: &str) -> Result<&ProtonDrivePublicLinkClient> { fn client(&self, share_id: &str) -> Result<&pstr_core::ShareClient> {
self.library self.library
.client(share_id) .client(share_id)
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open"))) .ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))
+42
View File
@@ -63,6 +63,12 @@ const MAX_CONCURRENT_BLOCKS_PER_READ: usize = 4;
/// bandwidth on cancelled prefetches. Well past a 1080p bitrate either way. /// bandwidth on cancelled prefetches. Well past a 1080p bitrate either way.
pub const DEFAULT_READAHEAD_BLOCKS: usize = 12; pub const DEFAULT_READAHEAD_BLOCKS: usize = 12;
/// How many blocks of one offline copy are fetched at once, for
/// [`VideoStream::blocks_in_order`]. The same as one read is allowed, for the
/// same reason: enough to hide a round trip, few enough that a couple of
/// copies running together do not swamp a player.
pub const COPY_BLOCKS_IN_FLIGHT: usize = 4;
/// How far a read may land from where the previous one ended and still count as /// How far a read may land from where the previous one ended and still count as
/// a continuation rather than a seek. /// a continuation rather than a seek.
/// ///
@@ -299,6 +305,42 @@ impl VideoStream {
Ok(buf) Ok(buf)
} }
/// Every block from `first` on, fetched `in_flight` at a time and yielded
/// in file order.
///
/// What an offline copy reads through. File order is what a partial file's
/// "its length is a block boundary" resume rule needs, and a few blocks in
/// flight is what keeps a copy from running one round trip per block. Use
/// it on a stream from [`crate::StreamSource::open_for_copy`]: a playback
/// stream would add its own read-ahead on top.
pub fn blocks_in_order(
&self,
first: usize,
in_flight: usize,
) -> futures::stream::BoxStream<'static, Result<Vec<u8>>> {
let start: u64 = self.block_sizes()[..first.min(self.block_sizes().len())]
.iter()
.sum();
let ranges: Vec<(u64, u64)> = self
.block_sizes()
.iter()
.skip(first)
.scan(start, |offset, size| {
let range = (*offset, *size);
*offset += size;
Some(range)
})
.collect();
let stream = self.clone();
stream::iter(ranges)
.map(move |(offset, size)| {
let stream = stream.clone();
async move { stream.read_range(offset, size).await }
})
.buffered(in_flight.max(1))
.boxed()
}
pub fn stats(&self) -> StreamStats { pub fn stats(&self) -> StreamStats {
StreamStats { StreamStats {
ring: self.inner.ring.stats(), ring: self.inner.ring.stats(),
+11 -1
View File
@@ -104,6 +104,13 @@ an edge case.
holds only the id, display name and token. holds only the id, display name and token.
- Nothing this app holds can write to the share: a viewer link grants - Nothing this app holds can write to the share: a viewer link grants
`MemberRole::Viewer`, and the SDK's public-link write side is unported anyway. `MemberRole::Viewer`, and the SDK's public-link write side is unported anyway.
- Signing in to a Proton account is optional (`pstr-core::account`). 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
credential store, under `proton-account`. Refresh tokens are single-use, so
every refresh is written back. The account writes exactly one thing: the
watch-history files on its own `proton-stream` device (`pstr-core::sync`).
Folders of the account added as shares are only ever read.
- 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
@@ -118,7 +125,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 | | Watch state | same DB, own table | **No** — deliberately survives a recrawl; with an account, mirrored to its Drive |
| Proton session | OS credential store, `proton-account` | No — sign in again |
| Sync identity | config dir, `sync.json` | A new id if missing — the old history file is still read |
| Watch history in Drive | device `proton-stream`, `watch-history/<id>.json` | One file per installation; merged per episode, newer wins |
| 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 |
+26
View File
@@ -146,6 +146,32 @@ and [B44](#b44--r8-broke-the-app-under-instrumentation-in-five-places).
## Fixed ## Fixed
### B64 — Android offline downloads looked stuck
**Symptom.** A queued episode, and more so a queued season, sat at a progress
bar that did not move for long stretches, then jumped.
**Cause.** Three, compounding. WorkManager starts every queued worker whose
constraints are met, so "Download season" was a dozen transfers at once, each
with a share of the link. Each read through the *playback* stream, so behind
every block it read sat twelve blocks of read-ahead competing for the same
bandwidth — and filling the shared memory ring and the disk block cache, a
second copy of every byte. And progress was reported once per completed
in-order block, so the first report waited on all of that. The desktop had none
of these: three downloads at most, a copy stream with no read-ahead, four
blocks in flight.
**Fix.** The Android engine allows two downloads to transfer at once; the rest
wait for a slot, reported as queued (the worker no longer marks itself running
before its first progress report, and its notification says "Waiting to
download"). Downloads open through `StreamSource::open_for_copy` and read with
`VideoStream::blocks_in_order` at `COPY_BLOCKS_IN_FLIGHT`, now shared with the
desktop engine. The rows show a smoothed transfer rate beside the bytes, so a
slow download reads as slow rather than stuck.
**Verified.** Workspace gate and the Android host check pass, with
`TransferRateTest` covering the rate. Not yet observed on a device.
### B63 — a taskbar pin on Windows lost its icon ### B63 — a taskbar pin on Windows lost its icon
**Symptom.** Proton Stream pinned to the Windows taskbar showed its icon at **Symptom.** Proton Stream pinned to the Windows taskbar showed its icon at
+4 -1
View File
@@ -161,7 +161,10 @@ reads an external subtitle through the same protocol); the work is in
## Design decisions already taken ## Design decisions already taken
- **Fat client, no server.** Each viewer's app talks to Proton directly. - **Fat client, no server.** Each viewer's app talks to Proton directly.
- **Public links only**, but several, merged into one catalog. No account login. - **Public links first**, several, merged into one catalog. A Proton account
is optional: it adds folders of the viewer's own Drive as shares, and watch
history synced through a `proton-stream` device in that Drive — one file per
installation, so no two devices ever write the same file.
- **Embedded libmpv**, not a bundled decoder stack. HEVC 10-bit, HDR and ASS - **Embedded libmpv**, not a bundled decoder stack. HEVC 10-bit, HDR and ASS
subtitles with hardware decode, for free. subtitles with hardware decode, for free.
- **No transcoding.** mpv demuxes and decodes natively; there is no remux or - **No transcoding.** mpv demuxes and decodes natively; there is no remux or