diff --git a/Cargo.lock b/Cargo.lock index afc55ec..0ddc59e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index f07b6f7..8dbcd53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/android/app/src/main/java/io/narl/protonstream/download/DownloadStateStore.kt b/android/app/src/main/java/io/narl/protonstream/download/DownloadStateStore.kt index b309bf7..530eea3 100644 --- a/android/app/src/main/java/io/narl/protonstream/download/DownloadStateStore.kt +++ b/android/app/src/main/java/io/narl/protonstream/download/DownloadStateStore.kt @@ -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"), ) } diff --git a/android/app/src/main/java/io/narl/protonstream/download/OfflineDownloadWorker.kt b/android/app/src/main/java/io/narl/protonstream/download/OfflineDownloadWorker.kt index b0b3c0f..2b80881 100644 --- a/android/app/src/main/java/io/narl/protonstream/download/OfflineDownloadWorker.kt +++ b/android/app/src/main/java/io/narl/protonstream/download/OfflineDownloadWorker.kt @@ -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) diff --git a/android/app/src/main/java/io/narl/protonstream/ui/AccountUi.kt b/android/app/src/main/java/io/narl/protonstream/ui/AccountUi.kt new file mode 100644 index 0000000..6ebd997 --- /dev/null +++ b/android/app/src/main/java/io/narl/protonstream/ui/AccountUi.kt @@ -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>() } + var places by remember { mutableStateOf?>(null) } + var entries by remember { mutableStateOf?>(null) } + var error by remember { mutableStateOf(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, + ) +} diff --git a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt index cb27bc7..5094c94 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt @@ -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? = 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 create(modelClass: Class): T = AppViewModel(context, workManager) as T diff --git a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt index 055385d..66220b2 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt @@ -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". * diff --git a/android/app/src/main/java/io/narl/protonstream/ui/DownloadsScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/DownloadsScreen.kt index 8ece293..2f32444 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/DownloadsScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/DownloadsScreen.kt @@ -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) }, diff --git a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt index 087bb72..433d3df 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt @@ -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() }, ) } diff --git a/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt index 481a702..d075cde 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt @@ -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(null) } var removing by remember { mutableStateOf(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) }, diff --git a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt index 2e5f431..5255f09 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt @@ -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>(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()) { diff --git a/android/app/src/test/java/io/narl/protonstream/download/TransferRateTest.kt b/android/app/src/test/java/io/narl/protonstream/download/TransferRateTest.kt new file mode 100644 index 0000000..c16236b --- /dev/null +++ b/android/app/src/test/java/io/narl/protonstream/download/TransferRateTest.kt @@ -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)) + } +} diff --git a/android/app/src/test/java/io/narl/protonstream/ui/AccountUiTest.kt b/android/app/src/test/java/io/narl/protonstream/ui/AccountUiTest.kt new file mode 100644 index 0000000..b18a6fb --- /dev/null +++ b/android/app/src/test/java/io/narl/protonstream/ui/AccountUiTest.kt @@ -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)) + } +} diff --git a/crates/pstr-android/Cargo.toml b/crates/pstr-android/Cargo.toml index c80b7e2..9481982 100644 --- a/crates/pstr-android/Cargo.toml +++ b/crates/pstr-android/Cargo.toml @@ -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 diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index b7d51ff..e6e3612 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -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, + 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 { + 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>>, + reusable_clients: Mutex>>, /// The whole library as bridge records, behind the catalog's write counter. library_cache: Mutex>, /// 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>, + /// A sign-in waiting for a code or the mailbox password. + pending_sign_in: Mutex>, + /// `None` when this installation's sync identity could not be written. + watch_sync: Option>, + /// 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, } +/// 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, + username: String, + password: String, + verification_token: Option, + ) -> Result { + 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, + code: String, + ) -> Result { + 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, + password: String, + ) -> Result { + 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) -> 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) -> Result, 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) -> Result, 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, + volume_id: String, + link_id: String, + ) -> Result, 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 { + 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, 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, + 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 { + 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 = failures .into_iter() .map(|(share, error)| format!("{}: {error}", share.name)) @@ -1923,6 +2321,27 @@ impl AndroidEngine { share_id: &str, uid: &NodeUid, ) -> Result { + 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 { + 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!( diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 56343e6..2377d81 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -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, }, + /// 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, /// 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) } diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs index dc8b874..63b536d 100644 --- a/crates/pstr-app/src/engine.rs +++ b/crates/pstr-app/src/engine.rs @@ -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), /// Completed local copies, for badges and online-only actions. OfflineFiles(HashSet), + /// 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), + /// One folder of the account, for the Drive browser. + Folder { + uid: NodeUid, + entries: Vec, + }, + /// 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, /// Bumped to stop the crawls in flight; each watches it from its start. crawl_cancel: Arc>, + accounts: AccountStore, + /// The signed-in Proton account, if any. + account: Arc>>, + /// 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>, + /// A sign-in waiting for a code or the mailbox password. + pending_sign_in: Arc>>, + /// `None` when this installation's sync identity could not be written. + watch_sync: Option>, events: Sender, 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>> { - 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) -> bool { loop { let current = *command.borrow_and_update(); diff --git a/crates/pstr-app/src/ui/account.rs b/crates/pstr-app/src/ui/account.rs new file mode 100644 index 0000000..d35b618 --- /dev/null +++ b/crates/pstr-app/src/ui/account.rs @@ -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, + /// 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, + 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>, + /// The listing of the folder at the end of the trail, once it arrives. + pub entries: Option<(NodeUid, Vec)>, + pub loading: bool, + pub error: Option, +} + +impl DriveBrowser { + pub fn places_arrived(&mut self, places: Vec) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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, +) { + 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, +) { + 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()); + } +} diff --git a/crates/pstr-app/src/ui/mod.rs b/crates/pstr-app/src/ui/mod.rs index ebcaedf..fb41be1 100644 --- a/crates/pstr-app/src/ui/mod.rs +++ b/crates/pstr-app/src/ui/mod.rs @@ -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; diff --git a/crates/pstr-app/src/ui/shares.rs b/crates/pstr-app/src/ui/shares.rs index 1bb3ce8..aadb466 100644 --- a/crates/pstr-app/src/ui/shares.rs +++ b/crates/pstr-app/src/ui/shares.rs @@ -18,6 +18,7 @@ pub fn show( shares: &[Share], library: &Library, form: &mut ShareForm, + account: &mut ui::account::AccountPanel, actions: &mut Vec, ) { 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, } } diff --git a/crates/pstr-cli/Cargo.toml b/crates/pstr-cli/Cargo.toml index 752dc4d..5ad7b7c 100644 --- a/crates/pstr-cli/Cargo.toml +++ b/crates/pstr-cli/Cargo.toml @@ -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 diff --git a/crates/pstr-cli/src/main.rs b/crates/pstr-cli/src/main.rs index 074ceb9..5de460d 100644 --- a/crates/pstr-cli/src/main.rs +++ b/crates/pstr-cli/src/main.rs @@ -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, + }, + /// 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) -> 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) -> 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
`"), + }, + + 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) -> 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) -> 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 { + accounts + .resume() + .context("resume the account")? + .ok_or_else(|| anyhow::anyhow!("not signed in — sign in with `pstr login
`")) +} + +/// 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 { + 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) diff --git a/crates/pstr-core/Cargo.toml b/crates/pstr-core/Cargo.toml index 61f3e12..5b19c95 100644 --- a/crates/pstr-core/Cargo.toml +++ b/crates/pstr-core/Cargo.toml @@ -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 diff --git a/crates/pstr-core/src/account.rs b/crates/pstr-core/src/account.rs new file mode 100644 index 0000000..0c82a66 --- /dev/null +++ b/crates/pstr-core/src/account.rs @@ -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, + /// `1` single password, `2` separate mailbox password — Proton's values. + password_mode: u8, + mailbox_password: String, + key_salts: Vec, +} + +impl StoredSession { + fn capture( + session: &ProtonApiSession, + tokens: proton_sdk::http::Tokens, + mailbox_password: &str, + key_salts: Vec, + ) -> 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> { + 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 = 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> { + let uids = self + .drive + .enumerate_folder_children_node_uids(folder) + .await?; + if uids.is_empty() { + return Ok(Vec::new()); + } + let mut entries: Vec = 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, + pub media_type: Option, +} + +/// 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, + 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 { + 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 { + finish( + Arc::clone(&self.secrets), + self.session.clone(), + mailbox_password, + ) + .await + } + + async fn after_second_factor(self) -> Result { + 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, +} + +impl AccountStore { + pub fn new(secrets: Arc) -> Self { + Self { secrets } + } + + /// The address of the stored account, without touching the network. + pub fn username(&self) -> Result> { + 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, + ) -> Result { + 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> { + 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> { + 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, + session: ProtonApiSession, + mailbox_password: &str, +) -> Result { + 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, + session: &ProtonApiSession, + mailbox_password: String, + key_salts: Vec, +) { + 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 { + 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>); + + 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> { + 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}"); + } + } +} diff --git a/crates/pstr-core/src/catalog.rs b/crates/pstr-core/src/catalog.rs index 0b8debe..8b5f912 100644 --- a/crates/pstr-core/src/catalog.rs +++ b/crates/pstr-core/src/catalog.rs @@ -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 { + 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> { 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"); diff --git a/crates/pstr-core/src/config.rs b/crates/pstr-core/src/config.rs index 0312d41..ee132e4 100644 --- a/crates/pstr-core/src/config.rs +++ b/crates/pstr-core/src/config.rs @@ -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") diff --git a/crates/pstr-core/src/error.rs b/crates/pstr-core/src/error.rs index 411a6d1..e704c3c 100644 --- a/crates/pstr-core/src/error.rs +++ b/crates/pstr-core/src/error.rs @@ -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), + + /// 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"))] diff --git a/crates/pstr-core/src/lib.rs b/crates/pstr-core/src/lib.rs index 0c9ba9b..7f8996f 100644 --- a/crates/pstr-core/src/lib.rs +++ b/crates/pstr-core/src/lib.rs @@ -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; diff --git a/crates/pstr-core/src/shares.rs b/crates/pstr-core/src/shares.rs index e14a953..528003c 100644 --- a/crates/pstr-core/src/shares.rs +++ b/crates/pstr-core/src/shares.rs @@ -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, +} + +/// 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 { + 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 { + /// Open a share: a link as a visitor, a folder through `account`. + pub async fn open(&self, share: &Share, account: Option<&Account>) -> Result { + 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, + clients: BTreeMap, +} + +/// 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), + /// 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 { + 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> { + 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> { + 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 { + 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> { + 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>> { + 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, + mut reusable: BTreeMap, + 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 { + /// + /// 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 { 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) } diff --git a/crates/pstr-core/src/sync.rs b/crates/pstr-core/src/sync.rs new file mode 100644 index 0000000..1b231a1 --- /dev/null +++ b/crates/pstr-core/src/sync.rs @@ -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, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct HistoryEntry { + share: String, + link: String, + position: f64, + #[serde(default)] + duration: Option, + 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, + entries: Vec, +} + +/// 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>, + /// 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>>, + /// The history folders, resolved once per account. Usually one; see + /// [`WatchSync::folders`]. + folders: Mutex>>, + /// 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 { + let path = dirs.sync_file(); + let installation = match read_json::(&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) -> Result { + 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 = 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, + ) -> Vec { + 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> { + 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> { + 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 { + 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); + } +} diff --git a/crates/pstr-meta/src/service.rs b/crates/pstr-meta/src/service.rs index f9f5696..3baa37e 100644 --- a/crates/pstr-meta/src/service.rs +++ b/crates/pstr-meta/src/service.rs @@ -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 { self.enrich(title, found) .await diff --git a/crates/pstr-stream/src/lib.rs b/crates/pstr-stream/src/lib.rs index f4ae885..b4a2775 100644 --- a/crates/pstr-stream/src/lib.rs +++ b/crates/pstr-stream/src/lib.rs @@ -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}; diff --git a/crates/pstr-stream/src/reader.rs b/crates/pstr-stream/src/reader.rs index 62e2b85..cc352d9 100644 --- a/crates/pstr-stream/src/reader.rs +++ b/crates/pstr-stream/src/reader.rs @@ -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"))) diff --git a/crates/pstr-stream/src/stream.rs b/crates/pstr-stream/src/stream.rs index 30d29e4..4868966 100644 --- a/crates/pstr-stream/src/stream.rs +++ b/crates/pstr-stream/src/stream.rs @@ -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>> { + 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(), diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4637c0a..9abeb9a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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/.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 | diff --git a/docs/BUGS.md b/docs/BUGS.md index 83fe739..41ab520 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -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 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index cd588d9..641523a 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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