mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
6cdab2fde1
commit
0b2813cd42
37 files changed
+4111
-159
No files matched your search
Generated
+4
@@ -4376,6 +4376,7 @@ name = "pstr-android"
|
||||
version = "1.1.3"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"futures",
|
||||
"jni 0.21.1",
|
||||
"log",
|
||||
"parking_lot",
|
||||
@@ -4428,6 +4429,7 @@ version = "1.1.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"parking_lot",
|
||||
"proton-drive-rs",
|
||||
"proton-sdk",
|
||||
"pstr-core",
|
||||
@@ -4446,7 +4448,9 @@ version = "1.1.3"
|
||||
dependencies = [
|
||||
"directories",
|
||||
"futures",
|
||||
"getrandom 0.3.4",
|
||||
"keyring",
|
||||
"parking_lot",
|
||||
"proton-drive-rs",
|
||||
"proton-sdk",
|
||||
"rusqlite",
|
||||
|
||||
@@ -54,6 +54,8 @@ directories = "5"
|
||||
# Local dates for the history page. Already in the tree through the SDK.
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
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.
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
# 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 status: String = STATUS_QUEUED,
|
||||
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"
|
||||
|
||||
@@ -99,6 +101,7 @@ class DownloadStateStore(context: Context) {
|
||||
.put("total", record.total)
|
||||
.put("status", record.status)
|
||||
.put("error", record.error)
|
||||
.put("rate", record.bytesPerSecond)
|
||||
.toString()
|
||||
|
||||
private fun decode(encoded: String): RetainedDownload {
|
||||
@@ -112,6 +115,7 @@ class DownloadStateStore(context: Context) {
|
||||
total = json.optLong("total"),
|
||||
status = json.optString("status", RetainedDownload.STATUS_QUEUED),
|
||||
error = json.optString("error").takeIf(String::isNotBlank),
|
||||
bytesPerSecond = json.optLong("rate"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,20 +25,24 @@ class OfflineDownloadWorker(
|
||||
) : CoroutineWorker(appContext, parameters) {
|
||||
override suspend fun doWork(): Result {
|
||||
createNotificationChannel()
|
||||
setForeground(createForegroundInfo(0))
|
||||
setForeground(createForegroundInfo(null))
|
||||
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 linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link"))
|
||||
val label = inputData.getString(KEY_LABEL) ?: linkId
|
||||
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)
|
||||
?: 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)
|
||||
|
||||
val observer = object : DownloadObserver {
|
||||
private var reportedAt = 0L
|
||||
private var reportedPercent = -1
|
||||
private val rate = TransferRate()
|
||||
|
||||
/**
|
||||
* 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 now = SystemClock.elapsedRealtime()
|
||||
val complete = total > 0UL && downloaded >= total
|
||||
val bytesPerSecond = rate.sample(downloaded.toLong(), now)
|
||||
if (!complete && percent == reportedPercent && now - reportedAt < PROGRESS_INTERVAL_MS) {
|
||||
return
|
||||
}
|
||||
@@ -66,6 +71,7 @@ class OfflineDownloadWorker(
|
||||
current.copy(
|
||||
downloaded = downloaded.toLong(),
|
||||
total = total.toLong(),
|
||||
bytesPerSecond = bytesPerSecond,
|
||||
status = when (current.status) {
|
||||
RetainedDownload.STATUS_PAUSED,
|
||||
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)
|
||||
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle("Making episode available offline")
|
||||
.setProgress(100, progress, progress == 0)
|
||||
.setContentTitle(
|
||||
if (progress == null) "Waiting to download" else "Making episode available offline",
|
||||
)
|
||||
.setProgress(100, progress ?: 0, progress == null || progress == 0)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
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 =
|
||||
(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.EpisodeRecord
|
||||
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(
|
||||
val loading: Boolean = true,
|
||||
@@ -62,6 +66,25 @@ data class AppUiState(
|
||||
val message: String? = null,
|
||||
/** What Undo on [message] puts back, when the message offers one. */
|
||||
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()
|
||||
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 {
|
||||
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) }
|
||||
|
||||
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
|
||||
* 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) } }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SYNC_INTERVAL_MS = 5 * 60 * 1000L
|
||||
}
|
||||
|
||||
class Factory(private val context: Context, private val workManager: WorkManager) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
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.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import io.narl.protonstream.download.RetainedDownload
|
||||
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".
|
||||
*
|
||||
|
||||
@@ -204,14 +204,7 @@ private fun PartialRow(
|
||||
headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) },
|
||||
supportingContent = {
|
||||
Column {
|
||||
Text(
|
||||
listOfNotNull(
|
||||
download.status.replaceFirstChar { it.uppercase() },
|
||||
download.total.takeIf { it > 0L }?.let {
|
||||
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
|
||||
},
|
||||
).joinToString(" · "),
|
||||
)
|
||||
Text(downloadStatusLine(download))
|
||||
if (download.total > 0L) {
|
||||
AccentProgress(
|
||||
progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) },
|
||||
|
||||
@@ -270,6 +270,18 @@ fun ProtonStreamApp(
|
||||
shareLink,
|
||||
onShareLinkTaken,
|
||||
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(
|
||||
state,
|
||||
@@ -308,7 +320,7 @@ fun ProtonStreamApp(
|
||||
onClose = {
|
||||
playingTitleKey = null
|
||||
playerMinimized = false
|
||||
model.reloadAfterMetadataChange()
|
||||
model.playerClosed()
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
@@ -338,7 +350,7 @@ fun ProtonStreamApp(
|
||||
onClose = {
|
||||
playingTitleKey = null
|
||||
playerMinimized = false
|
||||
model.reloadAfterMetadataChange()
|
||||
model.playerClosed()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.ui.window.SecureFlagPolicy
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
import uniffi.pstr_android.AccountState
|
||||
import uniffi.pstr_android.ShareRecord
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
@@ -56,16 +57,40 @@ internal fun SharesScreen(
|
||||
incomingLink: String?,
|
||||
onIncomingLinkTaken: () -> Unit,
|
||||
padding: PaddingValues,
|
||||
account: AccountUiState = AccountUiState(),
|
||||
accountActions: AccountActions? = null,
|
||||
) {
|
||||
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 removing by remember { mutableStateOf<ShareRecord?>(null) }
|
||||
TabPage("Shares", 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.")
|
||||
} else {
|
||||
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 ->
|
||||
ShareRow(
|
||||
share = share,
|
||||
@@ -95,6 +120,14 @@ internal fun SharesScreen(
|
||||
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 ->
|
||||
RepairShareDialog(
|
||||
share = share,
|
||||
@@ -139,7 +172,13 @@ private fun ShareRow(
|
||||
ListItem(
|
||||
headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) },
|
||||
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 = {
|
||||
Icon(
|
||||
@@ -164,11 +203,13 @@ private fun ShareRow(
|
||||
// Not a remove: a share whose secret has become unreadable
|
||||
// cannot be removed either, since removal deletes a secret
|
||||
// the store can no longer touch.
|
||||
DropdownMenuItem(
|
||||
text = { Text("Re-enter link") },
|
||||
leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) },
|
||||
onClick = { menu = false; onRepair() },
|
||||
)
|
||||
if (!share.fromAccount) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Re-enter link") },
|
||||
leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) },
|
||||
onClick = { menu = false; onRepair() },
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text("Remove") },
|
||||
leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) },
|
||||
|
||||
@@ -636,8 +636,7 @@ private fun EpisodeRow(
|
||||
fill = solid(MaterialTheme.colorScheme.tertiary),
|
||||
)
|
||||
Text(
|
||||
"${active.status.replaceFirstChar { it.uppercase() }} · " +
|
||||
"${formatBytes(active.downloaded.toULong())} of ${formatBytes(active.total.toULong())}",
|
||||
downloadStatusLine(active),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -654,6 +653,8 @@ internal fun ChangeMatchDialog(
|
||||
) {
|
||||
var term by remember(title.key) { mutableStateOf(title.canonicalName ?: title.name) }
|
||||
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()) }
|
||||
val scope = rememberCoroutineScope()
|
||||
AlertDialog(
|
||||
@@ -672,16 +673,26 @@ internal fun ChangeMatchDialog(
|
||||
searching = false
|
||||
}
|
||||
}) { 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)) {
|
||||
items(options, key = { "${it.provider}:${it.remoteId}" }) { option ->
|
||||
EdgedButton(
|
||||
onClick = {
|
||||
choosing = true
|
||||
scope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { NativeRuntime.engine().chooseMatch(title.key, option) }
|
||||
}.onSuccess { onChanged() }.onFailure(onError)
|
||||
choosing = false
|
||||
}
|
||||
},
|
||||
enabled = !choosing,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ pstr-core.workspace = true
|
||||
pstr-meta.workspace = true
|
||||
pstr-stream.workspace = true
|
||||
tokio.workspace = true
|
||||
futures.workspace = true
|
||||
parking_lot.workspace = true
|
||||
thiserror.workspace = true
|
||||
uniffi.workspace = true
|
||||
|
||||
+492
-51
@@ -10,6 +10,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use pstr_core::account::{DriveEntry, PendingSignIn, PlaceKind, SignIn};
|
||||
use pstr_core::appearance::{Accent, Appearance, Flavor, Palette};
|
||||
use pstr_core::browse::Sort;
|
||||
use pstr_core::catalog::{Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows};
|
||||
@@ -20,12 +21,17 @@ use pstr_core::metadata::{
|
||||
EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata, TitleNames,
|
||||
};
|
||||
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::{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::{
|
||||
BlockSource, DiskCacheConfig, FileBlocks, LibraryOpener, NodeUid, StreamConfig, StreamSource,
|
||||
VideoStream,
|
||||
BlockSource, COPY_BLOCKS_IN_FLIGHT, DiskCacheConfig, FileBlocks, LibraryOpener, NodeUid,
|
||||
StreamConfig, StreamSource, VideoStream,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -131,6 +137,102 @@ pub struct ShareRecord {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
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)]
|
||||
@@ -811,7 +913,7 @@ pub struct AndroidEngine {
|
||||
/// Held across the open handshakes so concurrent callers share one build.
|
||||
connecting: tokio::sync::Mutex<()>,
|
||||
/// 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.
|
||||
library_cache: Mutex<Option<CachedLibrary>>,
|
||||
/// 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,
|
||||
/// through [`AndroidEngine::set_stream_cache_budget`], the open one.
|
||||
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.
|
||||
///
|
||||
/// A refresh costs one round trip. Not refreshing costs an authentication
|
||||
@@ -854,6 +978,17 @@ impl AndroidEngine {
|
||||
Arc::clone(&secret_store),
|
||||
));
|
||||
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()
|
||||
.enable_all()
|
||||
.build()
|
||||
@@ -875,23 +1010,224 @@ impl AndroidEngine {
|
||||
thumbnails: tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY),
|
||||
session_refreshed: Mutex::new(HashMap::new()),
|
||||
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> {
|
||||
self.store
|
||||
.list()
|
||||
.map_err(BridgeError::from_display)
|
||||
.map(|shares| {
|
||||
shares
|
||||
.into_iter()
|
||||
.map(|share| ShareRecord {
|
||||
id: share.id,
|
||||
name: share.name,
|
||||
has_custom_password: share.has_custom_password,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.map(|shares| shares.into_iter().map(share_record).collect())
|
||||
}
|
||||
|
||||
pub fn add_share(
|
||||
@@ -905,11 +1241,7 @@ impl AndroidEngine {
|
||||
.add(&name, &url, custom_password.as_deref())
|
||||
.map_err(BridgeError::from_display)?;
|
||||
self.invalidate_connection(&share.id);
|
||||
Ok(ShareRecord {
|
||||
id: share.id,
|
||||
name: share.name,
|
||||
has_custom_password: share.has_custom_password,
|
||||
})
|
||||
Ok(share_record(share))
|
||||
}
|
||||
|
||||
/// 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())
|
||||
.map_err(BridgeError::from_display)?;
|
||||
self.invalidate_connection(&share.id);
|
||||
Ok(ShareRecord {
|
||||
id: share.id,
|
||||
name: share.name,
|
||||
has_custom_password: share.has_custom_password,
|
||||
})
|
||||
Ok(share_record(share))
|
||||
}
|
||||
|
||||
pub fn remove_share(&self, share_id: String) -> Result<(), BridgeError> {
|
||||
@@ -1163,19 +1491,47 @@ impl AndroidEngine {
|
||||
.map_err(BridgeError::from_display)?
|
||||
}
|
||||
|
||||
pub fn choose_match(&self, title_key: String, found: MatchRecord) -> Result<(), BridgeError> {
|
||||
let service = self.metadata_service()?;
|
||||
let metadata = title_metadata(found);
|
||||
if metadata.provider != service.provider() {
|
||||
return Err(BridgeError::Failure {
|
||||
reason: "match came from a different metadata provider".to_owned(),
|
||||
});
|
||||
}
|
||||
self.title(&title_key)?;
|
||||
self.catalog
|
||||
.lock()
|
||||
.set_metadata(&service.chosen(title_key, metadata))
|
||||
.map_err(BridgeError::from_display)
|
||||
/// 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 metadata = title_metadata(found);
|
||||
if metadata.provider != service.provider() {
|
||||
return Err(BridgeError::Failure {
|
||||
reason: "match came from a different metadata provider".to_owned(),
|
||||
});
|
||||
}
|
||||
let title = self.title(&title_key)?;
|
||||
let outcome = service.choose(&title, metadata).await;
|
||||
if let Some(record) = &outcome.record {
|
||||
self.catalog
|
||||
.lock()
|
||||
.set_metadata(record)
|
||||
.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> {
|
||||
@@ -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`.
|
||||
///
|
||||
/// The cached `Connection` is dropped here rather than left to be noticed
|
||||
@@ -1867,9 +2263,11 @@ impl AndroidEngine {
|
||||
let generation = self.share_generation.load(Ordering::Acquire);
|
||||
let reusable = self.reusable_clients.lock().take().unwrap_or_default();
|
||||
|
||||
let (library, failures) = SharedLibrary::open_all_reusing(&self.store, reusable)
|
||||
.await
|
||||
.map_err(BridgeError::from_display)?;
|
||||
let account = self.account.lock().clone();
|
||||
let (library, failures) =
|
||||
SharedLibrary::open_all_reusing(&self.store, reusable, account.as_ref())
|
||||
.await
|
||||
.map_err(BridgeError::from_display)?;
|
||||
let open_failures: Vec<String> = failures
|
||||
.into_iter()
|
||||
.map(|(share, error)| format!("{}: {error}", share.name))
|
||||
@@ -1923,6 +2321,27 @@ impl AndroidEngine {
|
||||
share_id: &str,
|
||||
uid: &NodeUid,
|
||||
) -> 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
|
||||
.session_refreshed
|
||||
.lock()
|
||||
@@ -1931,15 +2350,11 @@ impl AndroidEngine {
|
||||
if stale {
|
||||
self.refresh_session(connection, share_id).await;
|
||||
}
|
||||
match connection.source.open(share_id, uid).await {
|
||||
match open().await {
|
||||
Ok(stream) => Ok(stream),
|
||||
Err(_) if !stale => {
|
||||
self.refresh_session(connection, share_id).await;
|
||||
connection
|
||||
.source
|
||||
.open(share_id, uid)
|
||||
.await
|
||||
.map_err(BridgeError::from_display)
|
||||
open().await.map_err(BridgeError::from_display)
|
||||
}
|
||||
Err(error) => Err(BridgeError::from_display(error)),
|
||||
}
|
||||
@@ -2071,9 +2486,26 @@ impl AndroidEngine {
|
||||
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 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?;
|
||||
let revision_id = stream.revision_id().to_owned();
|
||||
let block_sizes = stream.block_sizes().to_vec();
|
||||
@@ -2125,7 +2557,13 @@ impl AndroidEngine {
|
||||
let mut output = prepare_partial_file(&temporary, offset).await?;
|
||||
report(observer.as_ref(), offset, total);
|
||||
|
||||
use futures::TryStreamExt as _;
|
||||
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() {
|
||||
if cancelled(observer.as_ref()) {
|
||||
output.sync_all().await.map_err(BridgeError::from_display)?;
|
||||
@@ -2134,10 +2572,13 @@ impl AndroidEngine {
|
||||
});
|
||||
}
|
||||
let size = block_sizes[block_index];
|
||||
let bytes = stream
|
||||
.read_range(offset, size)
|
||||
let bytes = fetches
|
||||
.try_next()
|
||||
.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 {
|
||||
return Err(BridgeError::Failure {
|
||||
reason: format!(
|
||||
|
||||
@@ -15,7 +15,8 @@ use pstr_core::library::Library;
|
||||
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, TitleNames};
|
||||
|
||||
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::playback::{Playback, PlaybackTarget};
|
||||
@@ -126,6 +127,25 @@ pub enum Action {
|
||||
url: 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.
|
||||
RemoveShare(String),
|
||||
ForgetShare(String),
|
||||
@@ -535,6 +555,8 @@ pub struct App {
|
||||
/// library means "not loaded", not "nothing in it".
|
||||
pub loaded: bool,
|
||||
pub form: ShareForm,
|
||||
/// The Proton account section of the shares page.
|
||||
pub account: ui::account::AccountPanel,
|
||||
pub playback: Option<Playback>,
|
||||
/// Whether the player page's controls are showing, and why.
|
||||
pub overlay: ui::player::Overlay,
|
||||
@@ -630,6 +652,7 @@ impl App {
|
||||
view: LibraryView::default(),
|
||||
loaded: false,
|
||||
form: ShareForm::default(),
|
||||
account: ui::account::AccountPanel::default(),
|
||||
playback: None,
|
||||
overlay: ui::player::Overlay::default(),
|
||||
up_next: UpNext::default(),
|
||||
@@ -882,6 +905,9 @@ impl App {
|
||||
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 {
|
||||
@@ -905,6 +931,23 @@ impl App {
|
||||
}
|
||||
}
|
||||
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.
|
||||
@@ -1369,6 +1412,20 @@ impl App {
|
||||
self.form.error = None;
|
||||
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::ForgetShare(id) => {
|
||||
self.confirm_remove_share = None;
|
||||
@@ -1868,6 +1925,7 @@ impl eframe::App for App {
|
||||
view,
|
||||
loaded,
|
||||
form,
|
||||
account,
|
||||
matcher,
|
||||
playback,
|
||||
opening,
|
||||
@@ -1964,7 +2022,9 @@ impl eframe::App for App {
|
||||
},
|
||||
&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 => {
|
||||
ui::settings::show(ui, settings, prefs, api_key, &mut actions)
|
||||
}
|
||||
|
||||
+314
-54
@@ -25,6 +25,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use pstr_core::account::{DriveEntry, DrivePlace, PendingSignIn, SignIn};
|
||||
use pstr_core::appearance::Appearance;
|
||||
use pstr_core::catalog::{
|
||||
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::proton_drive_rs::ThumbnailType;
|
||||
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 crate::desktop_prefs::{DesktopPrefs, DesktopPrefsFile};
|
||||
use pstr_stream::{
|
||||
BlockSource, DiskCacheConfig, FileBlocks, LibraryOpener, StreamConfig, StreamSource,
|
||||
VideoStream,
|
||||
BlockSource, COPY_BLOCKS_IN_FLIGHT, DiskCacheConfig, FileBlocks, LibraryOpener, StreamConfig,
|
||||
StreamSource, VideoStream,
|
||||
};
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
@@ -269,6 +272,38 @@ pub enum Event {
|
||||
DownloadProgress(Box<DownloadItem>),
|
||||
/// Completed local copies, for badges and online-only actions.
|
||||
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.
|
||||
@@ -335,6 +370,17 @@ pub struct Engine {
|
||||
desktop_file: Arc<DesktopPrefsFile>,
|
||||
/// Bumped to stop the crawls in flight; each watches it from its start.
|
||||
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>,
|
||||
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`].
|
||||
const LOOKUP_CONCURRENCY: usize = 2;
|
||||
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
|
||||
/// hide a round trip, few enough that three downloads do not swamp a player.
|
||||
const DOWNLOAD_BLOCKS_IN_FLIGHT: usize = 4;
|
||||
|
||||
/// How often watch history is synced while the app is open, on top of at
|
||||
/// launch and whenever a player stops.
|
||||
const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5 * 60);
|
||||
|
||||
/// Enrichment as currently configured.
|
||||
struct Enrichment {
|
||||
@@ -427,6 +473,11 @@ impl Engine {
|
||||
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| {
|
||||
tracing::warn!("read show track preferences: {error}");
|
||||
HashMap::new()
|
||||
@@ -449,6 +500,11 @@ impl Engine {
|
||||
download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)),
|
||||
picture_shown: Arc::new(AtomicBool::new(false)),
|
||||
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_file: Arc::new(desktop_file),
|
||||
events,
|
||||
@@ -460,6 +516,8 @@ impl Engine {
|
||||
deferred.install_enrichment(Enrichment::build(config), true);
|
||||
});
|
||||
}
|
||||
engine.load_account();
|
||||
engine.sync_periodically();
|
||||
Ok((engine, receiver))
|
||||
}
|
||||
|
||||
@@ -591,10 +649,239 @@ impl Engine {
|
||||
};
|
||||
engine.emit(Event::ShareAdded(share.name.clone()));
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn remove_share(&self, id: String) {
|
||||
let engine = self.clone();
|
||||
@@ -696,7 +983,11 @@ impl Engine {
|
||||
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,
|
||||
Err(error) => {
|
||||
self.emit(Event::ConnectFailed(error.to_string()));
|
||||
@@ -1555,8 +1846,8 @@ impl Engine {
|
||||
return Ok(DownloadEnd::Cancelled);
|
||||
}
|
||||
let size = block_sizes[block_index];
|
||||
let pending =
|
||||
fetches.get_or_insert_with(|| fetch_in_order(&stream, &block_sizes, block_index));
|
||||
let pending = fetches
|
||||
.get_or_insert_with(|| stream.blocks_in_order(block_index, COPY_BLOCKS_IN_FLIGHT));
|
||||
let Some(bytes) = pending.try_next().await? else {
|
||||
anyhow::bail!("block {block_index} never arrived");
|
||||
};
|
||||
@@ -2043,26 +2334,24 @@ impl Engine {
|
||||
return engine.emit(Event::Error(engine.no_service_reason()));
|
||||
};
|
||||
let name = found.name.clone();
|
||||
let record = service.chosen(title.key.clone(), found.clone());
|
||||
if let Err(error) = engine.catalog.lock().set_metadata(&record) {
|
||||
let outcome = service.choose(&title, found).await;
|
||||
if let Some(record) = &outcome.record
|
||||
&& let Err(error) = engine.catalog.lock().set_metadata(record)
|
||||
{
|
||||
return engine.fail("store the match", error);
|
||||
}
|
||||
|
||||
// A failure leaves the title un-enriched, and the next match run
|
||||
// asks again.
|
||||
match service.enrich(&title, &found).await {
|
||||
Ok(enrichment) => {
|
||||
let stored = engine.catalog.lock().set_enrichment(
|
||||
&title.key,
|
||||
service.provider(),
|
||||
now(),
|
||||
&enrichment,
|
||||
);
|
||||
if let Err(error) = stored {
|
||||
tracing::warn!("store enrichment for {}: {error}", title.key);
|
||||
}
|
||||
if let Some(enrichment) = &outcome.enrichment {
|
||||
let stored = engine.catalog.lock().set_enrichment(
|
||||
&title.key,
|
||||
service.provider(),
|
||||
now(),
|
||||
enrichment,
|
||||
);
|
||||
if let Err(error) = stored {
|
||||
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)));
|
||||
@@ -2223,35 +2512,6 @@ fn resume_position(existing: u64, block_sizes: &[u64]) -> (usize, u64) {
|
||||
(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 {
|
||||
loop {
|
||||
let current = *command.borrow_and_update();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! mutating the library it is iterating: pages collect [`Action`]s and the app
|
||||
//! applies them after the frame is drawn.
|
||||
|
||||
pub mod account;
|
||||
pub mod downloads;
|
||||
pub mod history;
|
||||
pub mod library;
|
||||
|
||||
@@ -18,6 +18,7 @@ pub fn show(
|
||||
shares: &[Share],
|
||||
library: &Library,
|
||||
form: &mut ShareForm,
|
||||
account: &mut ui::account::AccountPanel,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
egui::ScrollArea::vertical()
|
||||
@@ -40,6 +41,10 @@ pub fn show(
|
||||
|
||||
ui.add_space(theme::space::XXL);
|
||||
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
|
||||
// app. The id is still a hover away for a bug report.
|
||||
let mut detail = ui::library::plural(titles, "title");
|
||||
if share.folder.is_some() {
|
||||
detail.push_str(" · from your Drive");
|
||||
}
|
||||
if share.has_custom_password {
|
||||
detail.push_str(" · password protected");
|
||||
}
|
||||
@@ -211,6 +219,7 @@ mod tests {
|
||||
name: "anime".into(),
|
||||
token: token.into(),
|
||||
has_custom_password: false,
|
||||
folder: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,5 +23,6 @@ tokio.workspace = true
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
rpassword.workspace = true
|
||||
parking_lot.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
+186
-4
@@ -8,9 +8,12 @@
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||
use pstr_core::account::{PlaceKind, SignIn};
|
||||
use pstr_core::catalog::{Catalog, build_rows};
|
||||
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_stream::{DiskCacheConfig, LibraryOpener, StreamConfig, StreamSource};
|
||||
|
||||
@@ -69,8 +72,38 @@ enum Command {
|
||||
#[arg(long)]
|
||||
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.
|
||||
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.
|
||||
Remove {
|
||||
/// 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 dirs = AppDirs::ensure().context("resolve app directories")?;
|
||||
let store = ShareStore::new(dirs.clone());
|
||||
let accounts = AccountStore::new(std::sync::Arc::new(KeyringSecretStore));
|
||||
|
||||
match cli.command {
|
||||
Command::Add {
|
||||
@@ -228,6 +262,80 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
|
||||
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 => {
|
||||
let shares = store.list().context("list shares")?;
|
||||
if shares.is_empty() {
|
||||
@@ -239,7 +347,12 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
|
||||
} 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 } => {
|
||||
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
|
||||
.context("open shares")?;
|
||||
for (share, error) in &failures {
|
||||
@@ -643,7 +757,10 @@ async fn open_target(
|
||||
|
||||
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
|
||||
.context("open shares")?;
|
||||
for (share, error) in &failures {
|
||||
@@ -968,6 +1085,71 @@ async fn play(
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
||||
@@ -20,6 +20,8 @@ tracing.workspace = true
|
||||
directories.workspace = true
|
||||
rusqlite.workspace = true
|
||||
sha2.workspace = true
|
||||
parking_lot.workspace = true
|
||||
getrandom.workspace = true
|
||||
|
||||
# The credential store, per platform. Without the target's feature `keyring`
|
||||
# compiles fine and silently resolves to its in-memory `mock` store, which
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -721,6 +721,46 @@ impl Catalog {
|
||||
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.
|
||||
pub fn watch_state(&self, share_id: &str, link_id: &str) -> Result<Option<WatchState>> {
|
||||
let state = self
|
||||
@@ -2390,6 +2430,69 @@ mod tests {
|
||||
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]
|
||||
fn an_unwatched_file_has_no_watch_state() {
|
||||
let catalog = Catalog::in_memory().expect("open");
|
||||
|
||||
@@ -88,6 +88,12 @@ impl AppDirs {
|
||||
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.
|
||||
pub fn catalog_db(&self) -> PathBuf {
|
||||
self.data.join("catalog.db")
|
||||
|
||||
@@ -30,6 +30,37 @@ pub enum Error {
|
||||
/// The caller asked for something that does not exist or does not apply.
|
||||
#[error("{0}")]
|
||||
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"))]
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! how a share is opened and how its contents are modelled.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod account;
|
||||
pub mod appearance;
|
||||
pub mod browse;
|
||||
pub mod catalog;
|
||||
@@ -18,11 +19,13 @@ pub mod naming;
|
||||
pub mod prefs;
|
||||
pub mod shares;
|
||||
pub mod shelves;
|
||||
pub mod sync;
|
||||
|
||||
pub use account::{Account, AccountStore};
|
||||
pub use error::{Error, Result};
|
||||
#[cfg(not(target_os = "android"))]
|
||||
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_sdk;
|
||||
+213
-18
@@ -14,15 +14,28 @@
|
||||
//! 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
|
||||
//! 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::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::ids::NodeUid;
|
||||
use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::account::Account;
|
||||
use crate::config::{AppDirs, read_json, write_json};
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
@@ -92,6 +105,47 @@ pub struct Share {
|
||||
/// rather than after it fails.
|
||||
#[serde(default)]
|
||||
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.
|
||||
@@ -154,6 +208,31 @@ impl ShareStore {
|
||||
name: name.to_string(),
|
||||
token,
|
||||
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());
|
||||
write_json(&self.dirs.shares_file(), &shares)?;
|
||||
@@ -184,6 +263,11 @@ impl ShareStore {
|
||||
.iter_mut()
|
||||
.find(|share| share.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 {
|
||||
return Err(Error::Config(
|
||||
"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)
|
||||
}
|
||||
|
||||
/// Open a share as a visitor.
|
||||
pub async fn open(&self, share: &Share) -> Result<ProtonDrivePublicLinkClient> {
|
||||
/// Open a share: a link as a visitor, a folder through `account`.
|
||||
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 client = ProtonDrivePublicLinkClient::open(
|
||||
client_configuration(),
|
||||
@@ -234,7 +330,7 @@ impl ShareStore {
|
||||
// seeks wants more blocks in flight; at 4 MiB each this is a 192 MiB
|
||||
// ceiling, which is unremarkable for a desktop app.
|
||||
.with_max_inflight_blocks(48);
|
||||
Ok(client)
|
||||
Ok(ShareClient::PublicLink(Box::new(client)))
|
||||
}
|
||||
|
||||
fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> {
|
||||
@@ -256,7 +352,96 @@ impl ShareStore {
|
||||
|
||||
/// Several opened shares, presented as one library.
|
||||
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 {
|
||||
@@ -265,8 +450,14 @@ impl SharedLibrary {
|
||||
/// 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
|
||||
/// 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`.
|
||||
@@ -278,7 +469,8 @@ impl SharedLibrary {
|
||||
/// link.
|
||||
pub async fn open_all_reusing(
|
||||
store: &ShareStore,
|
||||
mut reusable: BTreeMap<String, ProtonDrivePublicLinkClient>,
|
||||
mut reusable: BTreeMap<String, ShareClient>,
|
||||
account: Option<&Account>,
|
||||
) -> Result<(Self, Vec<(Share, Error)>)> {
|
||||
let mut clients = BTreeMap::new();
|
||||
let mut failures = Vec::new();
|
||||
@@ -288,7 +480,7 @@ impl SharedLibrary {
|
||||
clients.insert(share.id, client);
|
||||
continue;
|
||||
}
|
||||
match store.open(&share).await {
|
||||
match store.open(&share, account).await {
|
||||
Ok(client) => {
|
||||
clients.insert(share.id.clone(), client);
|
||||
}
|
||||
@@ -305,19 +497,22 @@ impl SharedLibrary {
|
||||
/// The excluded share is the one whose credentials or membership just
|
||||
/// changed; leaving it out is what makes a removed share's live client
|
||||
/// unreachable rather than merely stale.
|
||||
pub fn reusable_clients(
|
||||
&self,
|
||||
dropping: &str,
|
||||
) -> BTreeMap<String, ProtonDrivePublicLinkClient> {
|
||||
///
|
||||
/// Folders of the account are never handed back: they carry the session
|
||||
/// they were opened with, and the account may have signed out or changed
|
||||
/// since.
|
||||
pub fn reusable_clients(&self, dropping: &str) -> BTreeMap<String, ShareClient> {
|
||||
self.clients
|
||||
.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()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
@@ -377,7 +572,7 @@ impl SharedLibrary {
|
||||
.client(share_id)
|
||||
.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 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"));
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
self.enrich(title, found)
|
||||
.await
|
||||
|
||||
@@ -42,4 +42,4 @@ pub use proton_sdk::ids::NodeUid;
|
||||
pub use reader::{FileBlocks, LibraryOpener, RevisionBlocks};
|
||||
pub use ring::{DEFAULT_RING_BYTES, RingStats};
|
||||
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};
|
||||
@@ -13,7 +13,6 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use proton_drive_rs::ProtonDrivePublicLinkClient;
|
||||
use proton_sdk::error::ProtonError;
|
||||
use proton_sdk::ids::NodeUid;
|
||||
|
||||
@@ -159,7 +158,7 @@ impl LibraryOpener {
|
||||
Self { library }
|
||||
}
|
||||
|
||||
fn client(&self, share_id: &str) -> Result<&ProtonDrivePublicLinkClient> {
|
||||
fn client(&self, share_id: &str) -> Result<&pstr_core::ShareClient> {
|
||||
self.library
|
||||
.client(share_id)
|
||||
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))
|
||||
|
||||
@@ -63,6 +63,12 @@ const MAX_CONCURRENT_BLOCKS_PER_READ: usize = 4;
|
||||
/// bandwidth on cancelled prefetches. Well past a 1080p bitrate either way.
|
||||
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
|
||||
/// a continuation rather than a seek.
|
||||
///
|
||||
@@ -299,6 +305,42 @@ impl VideoStream {
|
||||
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 {
|
||||
StreamStats {
|
||||
ring: self.inner.ring.stats(),
|
||||
|
||||
+11
-1
@@ -104,6 +104,13 @@ an edge case.
|
||||
holds only the id, display name and token.
|
||||
- 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.
|
||||
- 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
|
||||
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
|
||||
@@ -118,7 +125,10 @@ an edge case.
|
||||
| Share list | config dir, `shares.json` | **No** — atomic writes, never overwritten when unparseable |
|
||||
| Share secrets | OS credential store | **No** |
|
||||
| Catalog | data dir, `catalog.db` | Yes — recrawl |
|
||||
| Watch state | same DB, own table | **No** — deliberately survives a recrawl |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
@@ -146,6 +146,32 @@ and [B44](#b44--r8-broke-the-app-under-instrumentation-in-five-places).
|
||||
|
||||
## 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
|
||||
|
||||
**Symptom.** Proton Stream pinned to the Windows taskbar showed its icon at
|
||||
|
||||
+4
-1
@@ -161,7 +161,10 @@ reads an external subtitle through the same protocol); the work is in
|
||||
## Design decisions already taken
|
||||
|
||||
- **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
|
||||
subtitles with hardware decode, for free.
|
||||
- **No transcoding.** mpv demuxes and decodes natively; there is no remux or
|
||||
|
||||
Reference in new issue
Block a user