Compare commits

6 Commits
Author SHA1 Message Date
nvrl da16ab0575 fix(core,app,android): keep the password out of storage and refuse key-only 2FA
Moves to proton-sdk / proton-drive-rs 0.7.3.

The stored Proton session now holds, per account key, the passphrase derived
from the mailbox password at sign-in, and resumes through
`ProtonDriveClient::with_key_passphrases`. The mailbox password — on a
single-password account, the login password — and the key salts are no longer
stored. A session in the earlier shape, which only development builds of
1.2.0 wrote, reads as signed out: signing in again replaces it.

An account whose only second factor is a security key is refused at sign-in
with a reason, rather than asked for a code that can only fail as a wrong one.
The code prompts now say plainly that security keys are not supported.
2026-10-06 15:01:37 +02:00
nvrl d9ca8f8d60 fix(core,app,android,cli,packaging): harden account sign-in and watch-history sync
- Desktop shows Proton's CAPTCHA instead of failing the sign-in: a wry webview
  in a child process of the app (`pstr-app::verify`), confined to
  https://*.proton.me, hands the token back and the sign-in is retried. Linux
  packages and CI gain WebKitGTK 4.1; the Windows zip and MSI ship
  WebView2Loader.dll, without which a -gnu build does not start.
- The desktop keyring splits a secret too long for one entry across several,
  so a Proton session with many key salts survives Windows' 1280-unit cap.
  Short secrets stay one entry, so every existing secret still reads.
- `WatchSync` runs against a small `HistoryDrive` trait and is tested end to
  end against an in-memory Drive: two devices, newer wins, no re-upload of an
  unchanged history, a missing file written again, an unreadable own file
  never overwritten, a newer-format file left alone.
- An installation's file unwritten for 90 days is retired by the next device
  to sync, after its entries are merged and carried in that device's file.
- Removing a share keeps its watch state: sync would bring it back from the
  other devices anyway, and re-adding the share picks up where it left off.
- Watch history syncs once more on desktop exit (bounded to 3 s) and, on
  Android, through a WorkManager job enqueued when the app leaves the screen.
- The second-factor prompts say security keys are not supported.
2026-10-06 14:30:15 +02:00
nvrl 26cccd5c63 chore(version): bump to 1.2.0
Minor. Two new features, both optional and both behind signing in to a Proton
account:

- Sign in with a Proton account (password, two-factor code, separate mailbox
  password) on desktop, Android and the CLI, and add folders of your own Drive
  — My Files, other devices, shared with me — as library shares next to public
  links. The session lives in the OS credential store, never in shares.json.
- Watch history follows you across devices. Each installation keeps its own
  file on a `proton-stream` device in your Drive and merges everyone else's
  per episode, newest position wins. It syncs at launch, after sign-in, when
  the player closes and every five minutes.

Also: Android offline downloads no longer look stuck (B64), and picking a
metadata match by hand refreshes the title's episodes and artwork.

The catalog schema is unchanged from 1.1.3. A new `sync.json` beside
`shares.json` holds this installation's sync id.
2026-10-06 13:19:52 +02:00
nvrl 0b2813cd42 feat(core,stream,meta,app,android,cli): sign in to Proton and sync watch history
A Proton account is now optional alongside public links. Signing in (password,
then a second-factor code and a mailbox password where the account has them;
a CAPTCHA gate is solved in the Android app) stores one session blob in the OS
credential store under `proton-account`, writing back every refreshed token
since refresh tokens are single-use. With an account, folders of the viewer's
own Drive — My Files, other devices, shared with me — can be added as shares
through the same `ShareClient` surface as a public link.

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

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

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

The catalog schema is unchanged.
2026-10-06 13:06:54 +02:00
nvrl 6cdab2fde1 chore(version): bump to 1.1.3
Patch. Fixes two Windows installer defects: an MSI upgrade no longer leaves
the app without libmpv until a second install (B62), and a taskbar pin keeps
its icon, which the exe now carries itself (B63). The catalog schema and
on-disk data are unchanged from 1.1.2.
2026-10-06 03:05:20 +02:00
nvrl 6239780978 fix(app,packaging): keep libmpv and the taskbar icon across Windows upgrades
An MSI upgrade left the app without libmpv-2.dll until it was installed a
second time (B62). Costing runs before RemoveExistingProducts, saw the old
DLL at the same version and skipped the copy, and removing the old product
then deleted it. Both installer definitions now set REINSTALLMODE=amus;
every file is private to the install directory.

A taskbar pin lost its icon after a while (B63). The exe carried no icon
resource, and the Start Menu shortcut's icon lived in the per-ProductCode
installer cache that each upgrade deletes. build.rs now compiles the .ico
into proton-stream.exe, and the shortcut has no Icon of its own (and in the
WiX build is no longer advertised), so both show the exe's icon.
2026-10-06 03:04:07 +02:00
55 changed files with 6651 additions and 224 deletions

No files matched your search

+3 -3
View File
@@ -23,11 +23,11 @@ jobs:
- uses: Swatinem/rust-cache@v2.9.1 - uses: Swatinem/rust-cache@v2.9.1
# libmpv for pstr-player. # libmpv for pstr-player, WebKitGTK for the CAPTCHA window.
- name: Install libmpv - name: Install libmpv and WebKitGTK
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libmpv-dev sudo apt-get install -y libmpv-dev libwebkit2gtk-4.1-dev
- run: cargo fmt --all -- --check - run: cargo fmt --all -- --check
+1 -1
View File
@@ -76,7 +76,7 @@ jobs:
- name: Install build dependencies - name: Install build dependencies
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libmpv-dev rpm sudo apt-get install -y libmpv-dev libwebkit2gtk-4.1-dev rpm
- name: Gate - name: Gate
run: | run: |
Generated
+1275 -48
View File
File diff suppressed because it is too large. Load diff
+13 -3
View File
@@ -11,7 +11,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "1.1.2" version = "1.2.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://github.com/narrrl/proton-stream" repository = "https://github.com/narrrl/proton-stream"
@@ -28,8 +28,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
opt-level = 2 opt-level = 2
[workspace.dependencies] [workspace.dependencies]
proton-sdk = { version = "0.7.2" } proton-sdk = { version = "0.7.3" }
proton-drive-rs = { version = "0.7.2" } proton-drive-rs = { version = "0.7.3" }
# Internal crates. # Internal crates.
pstr-core = { path = "crates/pstr-core" } pstr-core = { path = "crates/pstr-core" }
@@ -54,6 +54,8 @@ directories = "5"
# Local dates for the history page. Already in the tree through the SDK. # Local dates for the history page. Already in the tree through the SDK.
chrono = { version = "0.4", default-features = false, features = ["clock"] } chrono = { version = "0.4", default-features = false, features = ["clock"] }
sha2 = "0.10" sha2 = "0.10"
# The id a watch-history file is named by. Already in the tree through the SDK.
getrandom = "0.3"
# `bundled` so a Windows build needs no system SQLite. # `bundled` so a Windows build needs no system SQLite.
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled"] }
# The share URL fragment and the custom password are both secrets; they go in # The share URL fragment and the custom password are both secrets; they go in
@@ -103,6 +105,14 @@ dbus = "0.9"
windows-sys = "0.61" windows-sys = "0.61"
# Toasts on Windows. Linux notifications go over the `dbus` above. # Toasts on Windows. Linux notifications go over the `dbus` above.
tauri-winrt-notification = "0.8" tauri-winrt-notification = "0.8"
# The exe's own icon resource, which is what Explorer and a taskbar pin draw.
embed-resource = "3"
# Proton's CAPTCHA page, which reports success by posting a message to its
# host, so it needs a webview this app controls. Run in a child process of its
# own (`pstr-app::verify`): tao and eframe's winit cannot share an event loop.
# WebKitGTK on Linux, WebView2 on Windows, WKWebView on macOS.
wry = { version = "0.57", default-features = false, features = ["os-webview"] }
tao = { version = "0.37", default-features = false, features = ["rwh_06"] }
rpassword = "7" rpassword = "7"
libloading = "0.8" libloading = "0.8"
lru = "0.18" lru = "0.18"
+3 -1
View File
@@ -83,7 +83,9 @@ Rust 2024, MSRV 1.96.
cargo build --release cargo build --release
``` ```
`pstr-player` needs libmpv development headers (`mpv` ≥ 2.0). On Windows those `pstr-player` needs libmpv development headers (`mpv` ≥ 2.0), and on Linux
`pstr-app` needs WebKitGTK 4.1's, for the window Proton's CAPTCHA is shown in.
On Windows the libmpv ones
come from the separate **mpv-dev** archive rather than mpv's player build, and come from the separate **mpv-dev** archive rather than mpv's player build, and
`libmpv-2.dll` ships next to the executable. The Proton SDK crates come from `libmpv-2.dll` ships next to the executable. The Proton SDK crates come from
crates.io; nothing here builds against a sibling checkout. crates.io; nothing here builds against a sibling checkout.
@@ -33,6 +33,7 @@ import io.narl.protonstream.playback.MpvPlaybackState
import io.narl.protonstream.playback.NativeMpvHost import io.narl.protonstream.playback.NativeMpvHost
import io.narl.protonstream.playback.PlaybackService import io.narl.protonstream.playback.PlaybackService
import io.narl.protonstream.settings.SettingsStore import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.sync.WatchSyncWorker
import io.narl.protonstream.ui.ProtonStreamApp import io.narl.protonstream.ui.ProtonStreamApp
import io.narl.protonstream.ui.theme.AppearanceState import io.narl.protonstream.ui.theme.AppearanceState
import io.narl.protonstream.ui.theme.ProtonStreamTheme import io.narl.protonstream.ui.theme.ProtonStreamTheme
@@ -207,6 +208,9 @@ class MainActivity : ComponentActivity() {
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
// Where the viewer stopped is what their other devices want next, and
// a process in the background may not live to the next timed sync.
if (!isChangingConfigurations) WatchSyncWorker.enqueue(this)
if ( if (
!isChangingConfigurations && !isChangingConfigurations &&
!isInPictureInPictureMode && !isInPictureInPictureMode &&
@@ -13,6 +13,8 @@ data class RetainedDownload(
val total: Long = 0, val total: Long = 0,
val status: String = STATUS_QUEUED, val status: String = STATUS_QUEUED,
val error: String? = null, val error: String? = null,
/** Recent transfer rate while running; 0 when unknown or not running. */
val bytesPerSecond: Long = 0,
) { ) {
val key: String get() = "$shareId\u001f$linkId" val key: String get() = "$shareId\u001f$linkId"
@@ -99,6 +101,7 @@ class DownloadStateStore(context: Context) {
.put("total", record.total) .put("total", record.total)
.put("status", record.status) .put("status", record.status)
.put("error", record.error) .put("error", record.error)
.put("rate", record.bytesPerSecond)
.toString() .toString()
private fun decode(encoded: String): RetainedDownload { private fun decode(encoded: String): RetainedDownload {
@@ -112,6 +115,7 @@ class DownloadStateStore(context: Context) {
total = json.optLong("total"), total = json.optLong("total"),
status = json.optString("status", RetainedDownload.STATUS_QUEUED), status = json.optString("status", RetainedDownload.STATUS_QUEUED),
error = json.optString("error").takeIf(String::isNotBlank), error = json.optString("error").takeIf(String::isNotBlank),
bytesPerSecond = json.optLong("rate"),
) )
} }
@@ -25,20 +25,24 @@ class OfflineDownloadWorker(
) : CoroutineWorker(appContext, parameters) { ) : CoroutineWorker(appContext, parameters) {
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
createNotificationChannel() createNotificationChannel()
setForeground(createForegroundInfo(0)) setForeground(createForegroundInfo(null))
val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share")) val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share"))
val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume")) val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume"))
val linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link")) val linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link"))
val label = inputData.getString(KEY_LABEL) ?: linkId val label = inputData.getString(KEY_LABEL) ?: linkId
val store = DownloadStateStore(applicationContext) val store = DownloadStateStore(applicationContext)
// Queued, not running: the engine lets two downloads transfer at once
// and holds the rest back, and only the first progress report means
// this one has a slot.
var retained = store.get(shareId, linkId) var retained = store.get(shareId, linkId)
?: RetainedDownload(shareId, volumeId, linkId, label) ?: RetainedDownload(shareId, volumeId, linkId, label)
retained = retained.copy(status = RetainedDownload.STATUS_RUNNING, error = null) retained = retained.copy(status = RetainedDownload.STATUS_QUEUED, error = null, bytesPerSecond = 0)
store.put(retained) store.put(retained)
val observer = object : DownloadObserver { val observer = object : DownloadObserver {
private var reportedAt = 0L private var reportedAt = 0L
private var reportedPercent = -1 private var reportedPercent = -1
private val rate = TransferRate()
/** /**
* Called once per 4 MiB block — a dozen times a second on a fast * Called once per 4 MiB block — a dozen times a second on a fast
@@ -51,6 +55,7 @@ class OfflineDownloadWorker(
val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt() val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt()
val now = SystemClock.elapsedRealtime() val now = SystemClock.elapsedRealtime()
val complete = total > 0UL && downloaded >= total val complete = total > 0UL && downloaded >= total
val bytesPerSecond = rate.sample(downloaded.toLong(), now)
if (!complete && percent == reportedPercent && now - reportedAt < PROGRESS_INTERVAL_MS) { if (!complete && percent == reportedPercent && now - reportedAt < PROGRESS_INTERVAL_MS) {
return return
} }
@@ -66,6 +71,7 @@ class OfflineDownloadWorker(
current.copy( current.copy(
downloaded = downloaded.toLong(), downloaded = downloaded.toLong(),
total = total.toLong(), total = total.toLong(),
bytesPerSecond = bytesPerSecond,
status = when (current.status) { status = when (current.status) {
RetainedDownload.STATUS_PAUSED, RetainedDownload.STATUS_PAUSED,
RetainedDownload.STATUS_CANCELLED, RetainedDownload.STATUS_CANCELLED,
@@ -114,11 +120,14 @@ class OfflineDownloadWorker(
) )
} }
private fun createForegroundInfo(progress: Int): ForegroundInfo { /** [progress] is a percentage, or null while waiting for a download slot. */
private fun createForegroundInfo(progress: Int?): ForegroundInfo {
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground) .setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
.setContentTitle("Making episode available offline") .setContentTitle(
.setProgress(100, progress, progress == 0) if (progress == null) "Waiting to download" else "Making episode available offline",
)
.setProgress(100, progress ?: 0, progress == null || progress == 0)
.setOngoing(true) .setOngoing(true)
.build() .build()
return ForegroundInfo( return ForegroundInfo(
@@ -146,5 +155,35 @@ class OfflineDownloadWorker(
} }
} }
/**
* Bytes per second over roughly the last few seconds.
*
* Progress arrives once per 4 MiB block, so on a slow link the bar moves in
* steps seconds apart; a rate beside it is what tells a slow download from a
* stuck one. Smoothed, because one block that took a little longer than the
* last would otherwise halve the figure for a moment.
*/
internal class TransferRate {
private var lastBytes = -1L
private var lastAt = 0L
private var smoothed = 0.0
/** Record [bytes] transferred at [now] (milliseconds) and return the rate. */
fun sample(bytes: Long, now: Long): Long {
if (lastBytes < 0 || bytes < lastBytes) {
lastBytes = bytes
lastAt = now
return smoothed.toLong()
}
val elapsed = now - lastAt
if (elapsed <= 0) return smoothed.toLong()
val instant = (bytes - lastBytes) * 1000.0 / elapsed
smoothed = if (smoothed > 0.0) 0.7 * smoothed + 0.3 * instant else instant
lastBytes = bytes
lastAt = now
return smoothed.toLong()
}
}
internal fun stableNotificationId(workId: UUID): Int = internal fun stableNotificationId(workId: UUID): Int =
(workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(1) (workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(1)
@@ -0,0 +1,56 @@
package io.narl.protonstream.sync
import android.content.Context
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import io.narl.protonstream.native.NativeRuntime
import uniffi.pstr_android.AccountState
import java.util.concurrent.TimeUnit
/**
* One watch-history sync after the app leaves the screen.
*
* The model's timer only runs while the app is alive, and Android is free to
* kill a process in the background without notice — so the position the
* viewer stopped at would wait for the next launch to reach their other
* devices. Handed to WorkManager instead, it goes as soon as there is a
* network, even if the process is gone by then.
*/
class WatchSyncWorker(
appContext: Context,
parameters: WorkerParameters,
) : CoroutineWorker(appContext, parameters) {
override suspend fun doWork(): Result {
val engine = NativeRuntime.engine()
return runCatching { engine.syncWatchHistory() }.fold(
onSuccess = { Result.success() },
onFailure = {
// Signed out by the engine — the session ended — is not
// something a retry fixes; a dropped connection is.
val signedOut = runCatching { engine.accountState() }.getOrNull() == AccountState.SignedOut
if (signedOut || runAttemptCount >= MAX_ATTEMPTS) Result.failure() else Result.retry()
},
)
}
companion object {
private const val WORK_NAME = "watch-history-sync"
private const val MAX_ATTEMPTS = 3
/** Replacing: one pending sync covers every position saved before it runs. */
fun enqueue(context: Context) {
val request = OneTimeWorkRequestBuilder<WatchSyncWorker>()
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context.applicationContext)
.enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request)
}
}
}
@@ -0,0 +1,506 @@
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("Code from your authenticator app") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
)
Text(SECURITY_KEY_NOTE, style = MaterialTheme.typography.bodySmall)
}
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)
}
},
)
}
}
}
/**
* Security keys need WebAuthn, which the sign-in here does not speak. An account
* with nothing else is refused before this form (`AccountStore::sign_in`).
*/
private const val SECURITY_KEY_NOTE =
"Security keys are not supported here — use the code from your authenticator app."
/** Installed once per page, at start and again at finish in case the first ran too early. */
private const val VERIFICATION_BRIDGE = """
if (!window.__pstrVerification) {
window.__pstrVerification = true;
window.addEventListener('message', function (event) {
try {
pstrVerification.post(typeof event.data === 'string' ? event.data : JSON.stringify(event.data));
} catch (e) {}
});
}
"""
/** Whether a page is Proton's own, over HTTPS. */
internal fun isProtonPage(scheme: String?, host: String?): Boolean =
scheme == "https" && host != null && (host == "proton.me" || host.endsWith(".proton.me"))
/**
* Walk the account's Drive and add a folder of it to the library.
*
* Full screen, because a Drive is deep and a dialog's height is a few rows of
* it. Listings are asked for here rather than through the model: they are
* this dialog's alone, and gone when it closes.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun DriveBrowserDialog(
actions: AccountActions,
onDismiss: () -> Unit,
) {
val trail = remember { mutableStateListOf<Pair<String, DrivePlaceRecord>>() }
var places by remember { mutableStateOf<List<DrivePlaceRecord>?>(null) }
var entries by remember { mutableStateOf<List<DriveEntryRecord>?>(null) }
var error by remember { mutableStateOf<String?>(null) }
val current = trail.lastOrNull()?.second
LaunchedEffect(current) {
error = null
if (current == null) {
if (places == null) {
runCatching { NativeRuntime.engine().drivePlaces() }
.onSuccess { places = it }
.onFailure { error = it.message }
}
} else {
entries = null
runCatching { NativeRuntime.engine().driveFolder(current.volumeId, current.linkId) }
.onSuccess { entries = it }
.onFailure { error = it.message }
}
}
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
BackHandler(enabled = trail.isNotEmpty()) { trail.removeAt(trail.lastIndex) }
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
trail.lastOrNull()?.first ?: "Your Drive",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
IconButton(onClick = { if (trail.isEmpty()) onDismiss() else trail.removeAt(trail.lastIndex) }) {
Icon(
if (trail.isEmpty()) Icons.Default.Close else Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = if (trail.isEmpty()) "Close" else "Up a folder",
)
}
},
)
},
bottomBar = {
// A folder already in the library is refused by the engine,
// with a message saying so.
val folder = current ?: return@Scaffold
Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.End) {
AccentButton(
onClick = {
actions.addFolder(trail.last().first, folder.volumeId, folder.linkId)
onDismiss()
},
) { Text("Add this folder") }
}
},
) { padding ->
Box(Modifier.fillMaxSize().padding(padding)) {
val loading = error == null && (if (current == null) places == null else entries == null)
when {
error != null -> EmptyState("Could not list this", error.orEmpty())
loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
current == null -> LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
items(places.orEmpty(), key = { "${it.volumeId}/${it.linkId}" }) { place ->
DriveRow(
icon = when (place.kind) {
PlaceType.MY_FILES -> Icons.Default.Storage
PlaceType.DEVICE -> Icons.Default.Computer
PlaceType.SHARED_WITH_ME -> Icons.Default.FolderShared
},
name = place.name,
detail = null,
onClick = { trail.add(place.name to place) },
)
}
}
entries.orEmpty().isEmpty() -> EmptyState("Empty folder", "Nothing to add from here.")
else -> LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
items(entries.orEmpty(), key = { "${it.volumeId}/${it.linkId}" }) { entry ->
DriveRow(
icon = when {
entry.isFolder -> Icons.Default.Folder
entry.isVideo -> Icons.Default.Movie
else -> Icons.Default.InsertDriveFile
},
name = entry.name,
detail = entry.size?.let(::formatBytes),
onClick = if (entry.isFolder) {
{
trail.add(
entry.name to DrivePlaceRecord(
PlaceType.MY_FILES,
entry.name,
entry.volumeId,
entry.linkId,
),
)
}
} else {
null
},
)
}
}
}
}
}
}
}
@Composable
private fun DriveRow(
icon: androidx.compose.ui.graphics.vector.ImageVector,
name: String,
detail: String?,
onClick: (() -> Unit)?,
) {
ListItem(
headlineContent = { Text(name, maxLines = 2, overflow = TextOverflow.Ellipsis) },
supportingContent = detail?.let { { Text(it) } },
leadingContent = {
Icon(
icon,
contentDescription = null,
tint = if (onClick != null) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier,
)
}
@@ -36,6 +36,10 @@ import uniffi.pstr_android.MetadataSettingsRecord
import uniffi.pstr_android.StorageUsageRecord import uniffi.pstr_android.StorageUsageRecord
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.WatchStateRecord import uniffi.pstr_android.WatchStateRecord
import uniffi.pstr_android.AccountState
import uniffi.pstr_android.SignInOutcome
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
data class AppUiState( data class AppUiState(
val loading: Boolean = true, val loading: Boolean = true,
@@ -62,6 +66,25 @@ data class AppUiState(
val message: String? = null, val message: String? = null,
/** What Undo on [message] puts back, when the message offers one. */ /** What Undo on [message] puts back, when the message offers one. */
val undo: List<WatchSnapshot>? = null, val undo: List<WatchSnapshot>? = null,
val account: AccountUiState = AccountUiState(),
)
/** The Proton account: where a sign-in stands, and how sync is doing. */
data class AccountUiState(
/** Null until the bridge has said. */
val state: AccountState? = null,
/** A sign-in step is out and not answered yet. */
val busy: Boolean = false,
/** Why the last sign-in step was refused. */
val error: String? = null,
/** The CAPTCHA page Proton wants solved before it accepts the sign-in. */
val verificationUrl: String? = null,
/** When watch history last synced, in epoch milliseconds. */
val syncedAt: Long? = null,
/** Positions the last sync took from other devices. */
val applied: Int = 0,
/** Why the last sync failed, until one succeeds. */
val syncError: String? = null,
) )
/** /**
@@ -84,6 +107,18 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
} }
} }
reload() reload()
viewModelScope.launch {
val account = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().accountState() } }
.getOrDefault(AccountState.SignedOut)
updateAccount { it.copy(state = account) }
// While the app is alive, on top of after sign-in and whenever the
// player closes. Cheap when nothing changed: one folder listing,
// and no upload.
while (isActive) {
syncWatchHistory()
delay(SYNC_INTERVAL_MS)
}
}
viewModelScope.launch { viewModelScope.launch {
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) } searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
} }
@@ -212,6 +247,148 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
fun dismissMessage() = mutableState.update { it.copy(message = null, undo = null) } fun dismissMessage() = mutableState.update { it.copy(message = null, undo = null) }
private fun updateAccount(change: (AccountUiState) -> AccountUiState) =
mutableState.update { it.copy(account = change(it.account)) }
/**
* Start signing in, or start again with [verificationToken] once the
* CAPTCHA from a first try is solved.
*/
fun signIn(username: String, password: String, verificationToken: String? = null) =
signInStep { engine -> engine.signIn(username, password, verificationToken) }
fun submitSecondFactor(code: String) = signInStep { engine -> engine.submitSecondFactor(code) }
fun submitMailboxPassword(password: String) =
signInStep(keepStepOnFailure = true) { engine -> engine.submitMailboxPassword(password) }
fun cancelSignIn() {
updateAccount { AccountUiState(state = AccountState.SignedOut) }
viewModelScope.launch { runCatching { NativeRuntime.engine().cancelSignIn() } }
}
/** The CAPTCHA page closed without a token. */
fun dismissVerification() = updateAccount { it.copy(verificationUrl = null, busy = false) }
private fun signInStep(
keepStepOnFailure: Boolean = false,
step: suspend (uniffi.pstr_android.AndroidEngine) -> SignInOutcome,
) {
updateAccount { it.copy(busy = true, error = null, verificationUrl = null) }
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { step(NativeRuntime.engine()) } }
.onSuccess { outcome ->
when (outcome) {
is SignInOutcome.Done -> {
updateAccount {
AccountUiState(state = AccountState.SignedIn(outcome.username))
}
mutableState.update { it.copy(message = "Signed in as ${outcome.username}") }
syncWatchHistory()
reload()
}
SignInOutcome.SecondFactor ->
updateAccount { it.copy(state = AccountState.SecondFactor, busy = false) }
SignInOutcome.MailboxPassword ->
updateAccount { it.copy(state = AccountState.MailboxPassword, busy = false) }
is SignInOutcome.HumanVerification ->
updateAccount { it.copy(verificationUrl = outcome.url) }
}
}
.onFailure { error ->
updateAccount {
it.copy(
busy = false,
error = error.message ?: "Sign-in failed",
// A refused code spends the half-made session.
state = if (keepStepOnFailure) it.state else AccountState.SignedOut,
)
}
}
}
}
fun signOut() {
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().signOut() } }
.onFailure(::reportError)
updateAccount { AccountUiState(state = AccountState.SignedOut) }
reload()
}
}
/** Sync now, from a tap: says so when it is done. */
fun syncNow() {
viewModelScope.launch {
if (syncWatchHistory()) {
val applied = mutableState.value.account.applied
mutableState.update {
it.copy(
message = if (applied > 0) {
"Watch history synced, $applied from other devices"
} else {
"Watch history is up to date"
},
)
}
}
}
}
/**
* Pull other devices' positions in and push this one's out. Quiet: it runs
* on a timer, and a failure is shown on the account card rather than as a
* snackbar every five minutes of an offline evening. Returns whether it
* synced.
*/
private suspend fun syncWatchHistory(): Boolean {
if (mutableState.value.account.state !is AccountState.SignedIn) return false
return runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().syncWatchHistory() } }
.fold(
onSuccess = { report ->
if (report == null) return false
updateAccount {
it.copy(
syncedAt = System.currentTimeMillis(),
applied = report.applied.toInt(),
syncError = null,
)
}
if (report.applied > 0u) reload()
true
},
onFailure = { error ->
val state = runCatching { NativeRuntime.engine().accountState() }.getOrNull()
updateAccount { it.copy(state = state ?: it.state, syncError = error.message) }
// Signed out by the engine: the session ended, and that is
// worth interrupting for, unlike a dropped connection.
if (state == AccountState.SignedOut) reportError(error)
false
},
)
}
/** After the player closes: where it stopped is what another device wants next. */
fun playerClosed() {
reload()
viewModelScope.launch { syncWatchHistory() }
}
fun addAccountFolder(name: String, volumeId: String, linkId: String) {
viewModelScope.launch {
mutableState.update { it.copy(refreshing = true) }
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
val share = engine.addAccountFolder(name, volumeId, linkId)
engine.crawl(share.id)
}
}.onFailure(::reportError)
mutableState.update { it.copy(refreshing = false) }
reload()
}
}
/** /**
* Every episode of a title watched or unwatched, with an Undo: a whole * Every episode of a title watched or unwatched, with an Undo: a whole
* title's positions are what this throws away, and a mis-tap on a tile's * title's positions are what this throws away, and a mis-tap on a tile's
@@ -501,6 +678,10 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } } }.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
} }
private companion object {
const val SYNC_INTERVAL_MS = 5 * 60 * 1000L
}
class Factory(private val context: Context, private val workManager: WorkManager) : ViewModelProvider.Factory { class Factory(private val context: Context, private val workManager: WorkManager) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = AppViewModel(context, workManager) as T override fun <T : ViewModel> create(modelClass: Class<T>): T = AppViewModel(context, workManager) as T
@@ -19,6 +19,7 @@ import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import io.narl.protonstream.download.RetainedDownload
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
/** /**
@@ -120,6 +121,20 @@ internal fun formatBytes(bytes: ULong): String {
} }
} }
/**
* One line under a download that has not finished: "Running · 120.0 MiB of
* 1.2 GiB · 2.4 MiB/s", or "Queued" while it waits for a slot.
*/
internal fun downloadStatusLine(download: RetainedDownload): String = listOfNotNull(
download.status.replaceFirstChar { it.uppercase() },
download.total.takeIf { it > 0L && download.downloaded > 0L }?.let {
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
},
download.bytesPerSecond
.takeIf { it > 0L && download.status == RetainedDownload.STATUS_RUNNING }
?.let { "${formatBytes(it.toULong())}/s" },
).joinToString(" · ")
/** /**
* What an episode row is headed with: "1. Mother and Children". * What an episode row is headed with: "1. Mother and Children".
* *
@@ -204,14 +204,7 @@ private fun PartialRow(
headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) }, headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) },
supportingContent = { supportingContent = {
Column { Column {
Text( Text(downloadStatusLine(download))
listOfNotNull(
download.status.replaceFirstChar { it.uppercase() },
download.total.takeIf { it > 0L }?.let {
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
},
).joinToString(" · "),
)
if (download.total > 0L) { if (download.total > 0L) {
AccentProgress( AccentProgress(
progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) }, progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) },
@@ -270,6 +270,18 @@ fun ProtonStreamApp(
shareLink, shareLink,
onShareLinkTaken, onShareLinkTaken,
body, body,
account = state.account,
accountActions = AccountActions(
signIn = model::signIn,
submitSecondFactor = model::submitSecondFactor,
submitMailboxPassword = model::submitMailboxPassword,
cancelSignIn = model::cancelSignIn,
dismissVerification = model::dismissVerification,
signOut = model::signOut,
syncNow = model::syncNow,
addFolder = model::addAccountFolder,
onError = model::reportError,
),
) )
Destination.History -> HistoryScreen( Destination.History -> HistoryScreen(
state, state,
@@ -308,7 +320,7 @@ fun ProtonStreamApp(
onClose = { onClose = {
playingTitleKey = null playingTitleKey = null
playerMinimized = false playerMinimized = false
model.reloadAfterMetadataChange() model.playerClosed()
}, },
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
@@ -338,7 +350,7 @@ fun ProtonStreamApp(
onClose = { onClose = {
playingTitleKey = null playingTitleKey = null
playerMinimized = false playerMinimized = false
model.reloadAfterMetadataChange() model.playerClosed()
}, },
) )
} }
@@ -32,6 +32,7 @@ import androidx.compose.ui.window.SecureFlagPolicy
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.narl.protonstream.ui.theme.AccentButton import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.TonalButton import io.narl.protonstream.ui.theme.TonalButton
import uniffi.pstr_android.AccountState
import uniffi.pstr_android.ShareRecord import uniffi.pstr_android.ShareRecord
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
@@ -56,16 +57,40 @@ internal fun SharesScreen(
incomingLink: String?, incomingLink: String?,
onIncomingLinkTaken: () -> Unit, onIncomingLinkTaken: () -> Unit,
padding: PaddingValues, padding: PaddingValues,
account: AccountUiState = AccountUiState(),
accountActions: AccountActions? = null,
) { ) {
var showAdd by remember { mutableStateOf(false) } var showAdd by remember { mutableStateOf(false) }
var signingIn by remember { mutableStateOf(false) }
var browsing by remember { mutableStateOf(false) }
var repairing by remember { mutableStateOf<ShareRecord?>(null) } var repairing by remember { mutableStateOf<ShareRecord?>(null) }
var removing by remember { mutableStateOf<ShareRecord?>(null) } var removing by remember { mutableStateOf<ShareRecord?>(null) }
TabPage("Shares", padding) { body -> TabPage("Shares", padding) { body ->
Box(Modifier.fillMaxSize().padding(body)) { Box(Modifier.fillMaxSize().padding(body)) {
if (shares.isEmpty()) { if (shares.isEmpty() && account.state == null) {
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.") EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
} else { } else {
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) { LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
if (accountActions != null) {
item(key = "account") {
AccountCard(
account,
accountActions,
onSignIn = { signingIn = true },
onBrowse = { browsing = true },
)
}
}
if (shares.isEmpty()) {
item(key = "empty") {
Text(
"No shares yet. Add a public link, or a folder of your Drive once signed in.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
}
items(shares, key = { it.id }) { share -> items(shares, key = { it.id }) { share ->
ShareRow( ShareRow(
share = share, share = share,
@@ -95,6 +120,14 @@ internal fun SharesScreen(
onAdd = onAdd, onAdd = onAdd,
) )
} }
if (accountActions != null && (signingIn || account.state == AccountState.SecondFactor ||
account.state == AccountState.MailboxPassword)
) {
SignInDialog(account, accountActions, onDismiss = { signingIn = false })
}
if (accountActions != null && browsing) {
DriveBrowserDialog(accountActions, onDismiss = { browsing = false })
}
repairing?.let { share -> repairing?.let { share ->
RepairShareDialog( RepairShareDialog(
share = share, share = share,
@@ -103,15 +136,15 @@ internal fun SharesScreen(
) )
} }
removing?.let { share -> removing?.let { share ->
// Asked, because it cannot be undone: the link's secret, its offline // Asked, because it cannot be undone: the link's secret and its
// files and its watch positions all go (`Catalog::remove_share`). // offline files go. Watch positions stay (`Catalog::remove_share`).
AlertDialog( AlertDialog(
onDismissRequest = { removing = null }, onDismissRequest = { removing = null },
title = { Text("Remove ${share.name}?") }, title = { Text("Remove ${share.name}?") },
text = { text = {
Text( Text(
"Its titles leave the library, and its offline episodes and watch " + "Its titles leave the library and its offline episodes are deleted. " +
"progress are deleted. Adding the link again starts it fresh.", "Watch progress is kept, so adding the link again picks up where you left off.",
) )
}, },
confirmButton = { confirmButton = {
@@ -139,7 +172,13 @@ private fun ShareRow(
ListItem( ListItem(
headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) }, headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) },
supportingContent = { supportingContent = {
Text(if (share.hasCustomPassword) "Link and custom password, stored securely" else "Public link") Text(
when {
share.fromAccount -> "Folder of your Drive"
share.hasCustomPassword -> "Link and custom password, stored securely"
else -> "Public link"
},
)
}, },
leadingContent = { leadingContent = {
Icon( Icon(
@@ -164,11 +203,13 @@ private fun ShareRow(
// Not a remove: a share whose secret has become unreadable // Not a remove: a share whose secret has become unreadable
// cannot be removed either, since removal deletes a secret // cannot be removed either, since removal deletes a secret
// the store can no longer touch. // the store can no longer touch.
if (!share.fromAccount) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Re-enter link") }, text = { Text("Re-enter link") },
leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) }, leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) },
onClick = { menu = false; onRepair() }, onClick = { menu = false; onRepair() },
) )
}
DropdownMenuItem( DropdownMenuItem(
text = { Text("Remove") }, text = { Text("Remove") },
leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) }, leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) },
@@ -636,8 +636,7 @@ private fun EpisodeRow(
fill = solid(MaterialTheme.colorScheme.tertiary), fill = solid(MaterialTheme.colorScheme.tertiary),
) )
Text( Text(
"${active.status.replaceFirstChar { it.uppercase() }} · " + downloadStatusLine(active),
"${formatBytes(active.downloaded.toULong())} of ${formatBytes(active.total.toULong())}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -654,6 +653,8 @@ internal fun ChangeMatchDialog(
) { ) {
var term by remember(title.key) { mutableStateOf(title.canonicalName ?: title.name) } var term by remember(title.key) { mutableStateOf(title.canonicalName ?: title.name) }
var searching by remember { mutableStateOf(false) } var searching by remember { mutableStateOf(false) }
// Choosing fetches the entry's episodes too, which is a few requests.
var choosing by remember { mutableStateOf(false) }
var options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) } var options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
AlertDialog( AlertDialog(
@@ -672,16 +673,26 @@ internal fun ChangeMatchDialog(
searching = false searching = false
} }
}) { Text(if (searching) "Searching…" else "Search") } }) { Text(if (searching) "Searching…" else "Search") }
if (choosing) {
Text(
"Fetching its episodes…",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 8.dp),
)
}
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 300.dp)) { LazyColumn(Modifier.fillMaxWidth().heightIn(max = 300.dp)) {
items(options, key = { "${it.provider}:${it.remoteId}" }) { option -> items(options, key = { "${it.provider}:${it.remoteId}" }) { option ->
EdgedButton( EdgedButton(
onClick = { onClick = {
choosing = true
scope.launch { scope.launch {
runCatching { runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().chooseMatch(title.key, option) } withContext(Dispatchers.IO) { NativeRuntime.engine().chooseMatch(title.key, option) }
}.onSuccess { onChanged() }.onFailure(onError) }.onSuccess { onChanged() }.onFailure(onError)
choosing = false
} }
}, },
enabled = !choosing,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp), modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
) { ) {
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
@@ -0,0 +1,37 @@
package io.narl.protonstream.download
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class TransferRateTest {
@Test
fun `the first sample has no rate to report`() {
assertEquals(0L, TransferRate().sample(4L * 1024 * 1024, 1_000))
}
@Test
fun `a steady transfer reports its rate`() {
val rate = TransferRate()
rate.sample(0, 0)
assertEquals(1_000_000L, rate.sample(1_000_000, 1_000))
assertEquals(1_000_000L, rate.sample(2_000_000, 2_000))
}
@Test
fun `one slow block moves the rate without halving it`() {
val rate = TransferRate()
rate.sample(0, 0)
rate.sample(1_000_000, 1_000)
val after = rate.sample(2_000_000, 3_000)
assertTrue(after in 600_000L..999_999L)
}
@Test
fun `a resumed download starting below the last sample starts over`() {
val rate = TransferRate()
rate.sample(0, 0)
rate.sample(1_000_000, 1_000)
assertEquals(1_000_000L, rate.sample(0, 2_000))
}
}
@@ -0,0 +1,38 @@
package io.narl.protonstream.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AccountUiTest {
@Test
fun `the sync line says how long ago and what came in`() {
val minute = 60_000L
assertEquals("Watch history syncs with your Drive", syncLine(AccountUiState(), 0))
assertEquals("Synced just now", syncLine(AccountUiState(syncedAt = 0), 30_000))
assertEquals("Synced 5 min ago", syncLine(AccountUiState(syncedAt = 0), 5 * minute))
assertEquals(
"Synced 2 h ago · 3 from other devices",
syncLine(AccountUiState(syncedAt = 0, applied = 3), 120 * minute),
)
}
@Test
fun `a failed sync is said in place of when it last worked`() {
assertEquals(
"Watch history did not sync: offline",
syncLine(AccountUiState(syncedAt = 0, syncError = "offline"), 0),
)
}
@Test
fun `the verification page may only navigate within Proton over https`() {
assertTrue(isProtonPage("https", "verify.proton.me"))
assertTrue(isProtonPage("https", "proton.me"))
assertFalse(isProtonPage("http", "verify.proton.me"))
assertFalse(isProtonPage("https", "proton.me.example.com"))
assertFalse(isProtonPage("https", "notproton.me"))
assertFalse(isProtonPage("https", null))
}
}
+1
View File
@@ -24,6 +24,7 @@ pstr-core.workspace = true
pstr-meta.workspace = true pstr-meta.workspace = true
pstr-stream.workspace = true pstr-stream.workspace = true
tokio.workspace = true tokio.workspace = true
futures.workspace = true
parking_lot.workspace = true parking_lot.workspace = true
thiserror.workspace = true thiserror.workspace = true
uniffi.workspace = true uniffi.workspace = true
+483 -41
View File
@@ -10,6 +10,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, OnceLock}; use std::sync::{Arc, OnceLock};
use parking_lot::Mutex; use parking_lot::Mutex;
use pstr_core::account::{DriveEntry, PendingSignIn, PlaceKind, SignIn};
use pstr_core::appearance::{Accent, Appearance, Flavor, Palette}; use pstr_core::appearance::{Accent, Appearance, Flavor, Palette};
use pstr_core::browse::Sort; use pstr_core::browse::Sort;
use pstr_core::catalog::{Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows}; use pstr_core::catalog::{Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows};
@@ -20,12 +21,17 @@ use pstr_core::metadata::{
EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata, TitleNames, EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata, TitleNames,
}; };
use pstr_core::prefs::PlaybackPrefs; use pstr_core::prefs::PlaybackPrefs;
use pstr_core::proton_drive_rs::{ProtonDrivePublicLinkClient, ThumbnailType}; use pstr_core::proton_drive_rs::ThumbnailType;
use pstr_core::proton_sdk::api::HumanVerificationCredential;
use pstr_core::proton_sdk::ids::{LinkId, VolumeId}; use pstr_core::proton_sdk::ids::{LinkId, VolumeId};
use pstr_core::{SecretStore, ShareStore, SharedLibrary}; use pstr_core::shares::AccountFolder;
use pstr_core::sync::WatchSync;
use pstr_core::{
Account, AccountStore, SecretStore, Share, ShareClient, ShareStore, SharedLibrary,
};
use pstr_stream::{ use pstr_stream::{
BlockSource, DiskCacheConfig, FileBlocks, LibraryOpener, NodeUid, StreamConfig, StreamSource, BlockSource, COPY_BLOCKS_IN_FLIGHT, DiskCacheConfig, FileBlocks, LibraryOpener, NodeUid,
VideoStream, StreamConfig, StreamSource, VideoStream,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -131,6 +137,102 @@ pub struct ShareRecord {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub has_custom_password: bool, pub has_custom_password: bool,
/// A folder of the signed-in account rather than a public link.
#[uniffi(default = false)]
pub from_account: bool,
}
fn share_record(share: Share) -> ShareRecord {
ShareRecord {
from_account: share.folder.is_some(),
id: share.id,
name: share.name,
has_custom_password: share.has_custom_password,
}
}
/// Where the Proton account stands.
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
pub enum AccountState {
SignedOut,
SignedIn {
username: String,
},
/// A sign-in is waiting for a second-factor code.
SecondFactor,
/// A sign-in is waiting for the mailbox password.
MailboxPassword,
}
/// What a sign-in step led to.
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
pub enum SignInOutcome {
Done {
username: String,
},
SecondFactor,
MailboxPassword,
/// Proton wants a CAPTCHA first. Show `url` in a WebView, read the token
/// out of its messages with [`verification_token`], and sign in again with
/// it.
HumanVerification {
url: String,
},
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct SyncRecord {
/// Episodes whose position another device had newer.
pub applied: u32,
pub uploaded: bool,
/// Other installations whose history was read.
pub devices: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum PlaceType {
MyFiles,
Device,
SharedWithMe,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct DrivePlaceRecord {
pub kind: PlaceType,
pub name: String,
pub volume_id: String,
pub link_id: String,
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct DriveEntryRecord {
pub name: String,
pub volume_id: String,
pub link_id: String,
pub is_folder: bool,
pub size: Option<u64>,
pub is_video: bool,
}
fn drive_entry_record(entry: DriveEntry) -> DriveEntryRecord {
DriveEntryRecord {
is_video: entry
.media_type
.as_deref()
.is_some_and(|media| media.starts_with("video/")),
name: entry.name,
volume_id: entry.uid.volume_id.as_str().to_owned(),
link_id: entry.uid.link_id.as_str().to_owned(),
is_folder: entry.is_folder,
size: entry.size.and_then(|size| u64::try_from(size).ok()),
}
}
/// The CAPTCHA token in a message the verification page posted, or `None` for
/// one of its other messages. See [`SignInOutcome::HumanVerification`].
#[uniffi::export]
pub fn verification_token(message: String) -> Option<String> {
pstr_core::account::verification_token(&message)
} }
#[derive(Debug, Clone, uniffi::Enum)] #[derive(Debug, Clone, uniffi::Enum)]
@@ -811,7 +913,7 @@ pub struct AndroidEngine {
/// Held across the open handshakes so concurrent callers share one build. /// Held across the open handshakes so concurrent callers share one build.
connecting: tokio::sync::Mutex<()>, connecting: tokio::sync::Mutex<()>,
/// Clients carried over from a retired connection, for the next build. /// Clients carried over from a retired connection, for the next build.
reusable_clients: Mutex<Option<BTreeMap<String, ProtonDrivePublicLinkClient>>>, reusable_clients: Mutex<Option<BTreeMap<String, ShareClient>>>,
/// The whole library as bridge records, behind the catalog's write counter. /// The whole library as bridge records, behind the catalog's write counter.
library_cache: Mutex<Option<CachedLibrary>>, library_cache: Mutex<Option<CachedLibrary>>,
/// Serializes offline publication with share removal cleanup. /// Serializes offline publication with share removal cleanup.
@@ -830,8 +932,30 @@ pub struct AndroidEngine {
/// The streaming cache's budget in bytes, for the next connection and, /// The streaming cache's budget in bytes, for the next connection and,
/// through [`AndroidEngine::set_stream_cache_budget`], the open one. /// through [`AndroidEngine::set_stream_cache_budget`], the open one.
cache_budget: AtomicU64, cache_budget: AtomicU64,
accounts: AccountStore,
/// The signed-in Proton account, if any.
account: Mutex<Option<Account>>,
/// A sign-in waiting for a code or the mailbox password.
pending_sign_in: Mutex<Option<PendingSignIn>>,
/// `None` when this installation's sync identity could not be written.
watch_sync: Option<Arc<WatchSync>>,
/// How many offline downloads may transfer at once.
///
/// WorkManager starts every queued worker whose constraints are met, so
/// "Download season" on twelve episodes was twelve transfers splitting one
/// link: each crawled, and none reported progress for long enough to look
/// stuck. The rest wait here, reported as queued, and start as one ends.
download_permits: Arc<tokio::sync::Semaphore>,
} }
/// How many offline downloads transfer at once. See
/// [`AndroidEngine::download_permits`]; the desktop runs three, a phone link
/// is better served by two.
const OFFLINE_DOWNLOAD_CONCURRENCY: usize = 2;
/// How often a download waiting for a slot asks whether it was cancelled.
const DOWNLOAD_WAIT_POLL: std::time::Duration = std::time::Duration::from_millis(500);
/// How long a visitor session is assumed good for without a refresh. /// How long a visitor session is assumed good for without a refresh.
/// ///
/// A refresh costs one round trip. Not refreshing costs an authentication /// A refresh costs one round trip. Not refreshing costs an authentication
@@ -854,6 +978,17 @@ impl AndroidEngine {
Arc::clone(&secret_store), Arc::clone(&secret_store),
)); ));
let catalog = Catalog::open(&dirs.catalog_db()).map_err(BridgeError::from_display)?; let catalog = Catalog::open(&dirs.catalog_db()).map_err(BridgeError::from_display)?;
let accounts = AccountStore::new(Arc::clone(&secret_store));
// An unreadable session is a signed-out app, not one that will not
// start: the library is the point, and the account is an extra.
let account = accounts.resume().unwrap_or_else(|error| {
log::warn!("resume the Proton session: {error}");
None
});
let watch_sync = WatchSync::open(&dirs)
.inspect_err(|error| log::warn!("watch-history sync unavailable: {error}"))
.ok()
.map(Arc::new);
let runtime = tokio::runtime::Builder::new_multi_thread() let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all() .enable_all()
.build() .build()
@@ -875,23 +1010,224 @@ impl AndroidEngine {
thumbnails: tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY), thumbnails: tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY),
session_refreshed: Mutex::new(HashMap::new()), session_refreshed: Mutex::new(HashMap::new()),
cache_budget: AtomicU64::new(DiskCacheConfig::DEFAULT_BUDGET_BYTES), cache_budget: AtomicU64::new(DiskCacheConfig::DEFAULT_BUDGET_BYTES),
download_permits: Arc::new(tokio::sync::Semaphore::new(OFFLINE_DOWNLOAD_CONCURRENCY)),
accounts,
account: Mutex::new(account),
pending_sign_in: Mutex::new(None),
watch_sync,
})) }))
} }
pub fn account_state(&self) -> AccountState {
if let Some(account) = self.account.lock().as_ref() {
return AccountState::SignedIn {
username: account.username().to_owned(),
};
}
// A pending sign-in is only ever reached through `sign_in`, whose
// outcome already said which of the two it is waiting for; the
// distinction is not kept, and a screen rebuilt mid-sign-in starts it
// over.
AccountState::SignedOut
}
/// Start signing in. `verification_token` is the CAPTCHA answer, after a
/// first attempt came back [`SignInOutcome::HumanVerification`].
pub async fn sign_in(
self: Arc<Self>,
username: String,
password: String,
verification_token: Option<String>,
) -> Result<SignInOutcome, BridgeError> {
let runtime = Arc::clone(&self.runtime);
spawned(&runtime, async move {
let verification = verification_token.map(HumanVerificationCredential::captcha);
match self
.accounts
.sign_in(&username, &password, verification)
.await
{
Ok(step) => Ok(self.sign_in_step(step)),
Err(pstr_core::Error::HumanVerification(challenge)) => {
Ok(SignInOutcome::HumanVerification {
url: challenge.verification_url(),
})
}
Err(error) => Err(BridgeError::from_display(error)),
}
})
.await
.map_err(BridgeError::from_display)?
}
/// A wrong code ends the attempt: the half-made session is spent, and the
/// viewer signs in again from the start.
pub async fn submit_second_factor(
self: Arc<Self>,
code: String,
) -> Result<SignInOutcome, BridgeError> {
let runtime = Arc::clone(&self.runtime);
spawned(&runtime, async move {
let pending =
self.pending_sign_in
.lock()
.take()
.ok_or_else(|| BridgeError::Failure {
reason: "that sign-in expired; start again".to_owned(),
})?;
let step = pending
.second_factor(&code)
.await
.map_err(BridgeError::from_display)?;
Ok(self.sign_in_step(step))
})
.await
.map_err(BridgeError::from_display)?
}
/// A wrong mailbox password keeps the sign-in, so it can be typed again.
pub async fn submit_mailbox_password(
self: Arc<Self>,
password: String,
) -> Result<SignInOutcome, BridgeError> {
let runtime = Arc::clone(&self.runtime);
spawned(&runtime, async move {
let pending =
self.pending_sign_in
.lock()
.take()
.ok_or_else(|| BridgeError::Failure {
reason: "that sign-in expired; start again".to_owned(),
})?;
match pending.mailbox_password(&password).await {
Ok(account) => Ok(self.sign_in_step(SignIn::Done(account))),
Err(error) => {
*self.pending_sign_in.lock() = Some(pending);
Err(BridgeError::from_display(error))
}
}
})
.await
.map_err(BridgeError::from_display)?
}
pub fn cancel_sign_in(&self) {
self.pending_sign_in.lock().take();
}
/// Sign out and forget the stored session. The library keeps everything;
/// the account's folders stop opening until the next sign-in.
pub async fn sign_out(self: Arc<Self>) -> Result<(), BridgeError> {
let runtime = Arc::clone(&self.runtime);
spawned(&runtime, async move {
let account = self.account.lock().take();
let result = self.accounts.sign_out(account.as_ref()).await;
self.signed_out();
result.map_err(BridgeError::from_display)
})
.await
.map_err(BridgeError::from_display)?
}
/// Sync watch history with the account's Drive. `None` when nobody is
/// signed in.
///
/// A session that has ended — revoked elsewhere, or idle too long — signs
/// the app out and fails with why, rather than failing the same way on
/// every later call.
pub async fn sync_watch_history(self: Arc<Self>) -> Result<Option<SyncRecord>, BridgeError> {
let runtime = Arc::clone(&self.runtime);
spawned(&runtime, async move {
let (Some(account), Some(sync)) =
(self.account.lock().clone(), self.watch_sync.clone())
else {
return Ok(None);
};
match sync.sync(&account, &self.catalog).await {
Ok(report) => Ok(Some(SyncRecord {
applied: u32::try_from(report.applied).unwrap_or(u32::MAX),
uploaded: report.uploaded,
devices: u32::try_from(report.devices).unwrap_or(u32::MAX),
})),
Err(error) if error.is_signed_out() => {
let _ = self.accounts.sign_out(None).await;
self.signed_out();
Err(BridgeError::Failure {
reason: "Your Proton session ended. Sign in again to keep syncing."
.to_owned(),
})
}
Err(error) => Err(BridgeError::from_display(error)),
}
})
.await
.map_err(BridgeError::from_display)?
}
/// The account's top-level places: My Files, devices, shared with me.
pub async fn drive_places(self: Arc<Self>) -> Result<Vec<DrivePlaceRecord>, BridgeError> {
let runtime = Arc::clone(&self.runtime);
spawned(&runtime, async move {
let account = self.signed_in_account()?;
let places = account.places().await.map_err(BridgeError::from_display)?;
Ok(places
.into_iter()
.map(|place| DrivePlaceRecord {
kind: match place.kind {
PlaceKind::MyFiles => PlaceType::MyFiles,
PlaceKind::Device => PlaceType::Device,
PlaceKind::SharedWithMe => PlaceType::SharedWithMe,
},
name: place.name,
volume_id: place.uid.volume_id.as_str().to_owned(),
link_id: place.uid.link_id.as_str().to_owned(),
})
.collect())
})
.await
.map_err(BridgeError::from_display)?
}
/// One folder of the account: folders first, then files.
pub async fn drive_folder(
self: Arc<Self>,
volume_id: String,
link_id: String,
) -> Result<Vec<DriveEntryRecord>, BridgeError> {
let runtime = Arc::clone(&self.runtime);
spawned(&runtime, async move {
let account = self.signed_in_account()?;
let entries = account
.folder_children(&node_uid(&volume_id, &link_id))
.await
.map_err(BridgeError::from_display)?;
Ok(entries.into_iter().map(drive_entry_record).collect())
})
.await
.map_err(BridgeError::from_display)?
}
/// Add a folder of the account as a share. Crawl it next, as after
/// [`Self::add_share`].
pub fn add_account_folder(
&self,
name: String,
volume_id: String,
link_id: String,
) -> Result<ShareRecord, BridgeError> {
let share = self
.store
.add_folder(&name, AccountFolder { volume_id, link_id })
.map_err(BridgeError::from_display)?;
self.invalidate_connection(&share.id);
Ok(share_record(share))
}
pub fn shares(&self) -> Result<Vec<ShareRecord>, BridgeError> { pub fn shares(&self) -> Result<Vec<ShareRecord>, BridgeError> {
self.store self.store
.list() .list()
.map_err(BridgeError::from_display) .map_err(BridgeError::from_display)
.map(|shares| { .map(|shares| shares.into_iter().map(share_record).collect())
shares
.into_iter()
.map(|share| ShareRecord {
id: share.id,
name: share.name,
has_custom_password: share.has_custom_password,
})
.collect()
})
} }
pub fn add_share( pub fn add_share(
@@ -905,11 +1241,7 @@ impl AndroidEngine {
.add(&name, &url, custom_password.as_deref()) .add(&name, &url, custom_password.as_deref())
.map_err(BridgeError::from_display)?; .map_err(BridgeError::from_display)?;
self.invalidate_connection(&share.id); self.invalidate_connection(&share.id);
Ok(ShareRecord { Ok(share_record(share))
id: share.id,
name: share.name,
has_custom_password: share.has_custom_password,
})
} }
/// Re-supply the link behind a share whose stored secret is unreadable. /// Re-supply the link behind a share whose stored secret is unreadable.
@@ -928,11 +1260,7 @@ impl AndroidEngine {
.replace_secrets(&share_id, &url, custom_password.as_deref()) .replace_secrets(&share_id, &url, custom_password.as_deref())
.map_err(BridgeError::from_display)?; .map_err(BridgeError::from_display)?;
self.invalidate_connection(&share.id); self.invalidate_connection(&share.id);
Ok(ShareRecord { Ok(share_record(share))
id: share.id,
name: share.name,
has_custom_password: share.has_custom_password,
})
} }
pub fn remove_share(&self, share_id: String) -> Result<(), BridgeError> { pub fn remove_share(&self, share_id: String) -> Result<(), BridgeError> {
@@ -1163,7 +1491,17 @@ impl AndroidEngine {
.map_err(BridgeError::from_display)? .map_err(BridgeError::from_display)?
} }
pub fn choose_match(&self, title_key: String, found: MatchRecord) -> Result<(), BridgeError> { /// Pin a title to the entry the viewer picked, then fetch that entry's
/// episodes, backdrop and franchise chain — the same enrichment a match run
/// does, so the title page shows the new match's episodes as soon as this
/// returns rather than after the next run.
pub async fn choose_match(
self: Arc<Self>,
title_key: String,
found: MatchRecord,
) -> Result<(), BridgeError> {
let runtime = Arc::clone(&self.runtime);
spawned(&runtime, async move {
let service = self.metadata_service()?; let service = self.metadata_service()?;
let metadata = title_metadata(found); let metadata = title_metadata(found);
if metadata.provider != service.provider() { if metadata.provider != service.provider() {
@@ -1171,11 +1509,29 @@ impl AndroidEngine {
reason: "match came from a different metadata provider".to_owned(), reason: "match came from a different metadata provider".to_owned(),
}); });
} }
self.title(&title_key)?; let title = self.title(&title_key)?;
let outcome = service.choose(&title, metadata).await;
if let Some(record) = &outcome.record {
self.catalog self.catalog
.lock() .lock()
.set_metadata(&service.chosen(title_key, metadata)) .set_metadata(record)
.map_err(BridgeError::from_display) .map_err(BridgeError::from_display)?;
}
if let Some(enrichment) = &outcome.enrichment {
let stored = self.catalog.lock().set_enrichment(
&title_key,
service.provider(),
now(),
enrichment,
);
if let Err(error) = stored {
log::warn!("store enrichment for {title_key}: {error}");
}
}
Ok(())
})
.await
.map_err(BridgeError::from_display)?
} }
pub fn forget_match(&self, title_key: String) -> Result<(), BridgeError> { pub fn forget_match(&self, title_key: String) -> Result<(), BridgeError> {
@@ -1754,6 +2110,46 @@ impl AndroidEngine {
}) })
} }
fn sign_in_step(&self, step: SignIn) -> SignInOutcome {
match step {
SignIn::Done(account) => {
let username = account.username().to_owned();
*self.account.lock() = Some(account);
if let Some(sync) = &self.watch_sync {
sync.reset();
}
// Folders of the account that failed to open are openable now.
self.invalidate_connection("");
SignInOutcome::Done { username }
}
SignIn::SecondFactor(pending) => {
*self.pending_sign_in.lock() = Some(pending);
SignInOutcome::SecondFactor
}
SignIn::MailboxPassword(pending) => {
*self.pending_sign_in.lock() = Some(pending);
SignInOutcome::MailboxPassword
}
}
}
fn signed_out(&self) {
self.account.lock().take();
if let Some(sync) = &self.watch_sync {
sync.reset();
}
self.invalidate_connection("");
}
fn signed_in_account(&self) -> Result<Account, BridgeError> {
self.account
.lock()
.clone()
.ok_or_else(|| BridgeError::Failure {
reason: "not signed in to Proton".to_owned(),
})
}
/// Retire the cached connection after a mutation of `share_id`. /// Retire the cached connection after a mutation of `share_id`.
/// ///
/// The cached `Connection` is dropped here rather than left to be noticed /// The cached `Connection` is dropped here rather than left to be noticed
@@ -1867,7 +2263,9 @@ impl AndroidEngine {
let generation = self.share_generation.load(Ordering::Acquire); let generation = self.share_generation.load(Ordering::Acquire);
let reusable = self.reusable_clients.lock().take().unwrap_or_default(); let reusable = self.reusable_clients.lock().take().unwrap_or_default();
let (library, failures) = SharedLibrary::open_all_reusing(&self.store, reusable) let account = self.account.lock().clone();
let (library, failures) =
SharedLibrary::open_all_reusing(&self.store, reusable, account.as_ref())
.await .await
.map_err(BridgeError::from_display)?; .map_err(BridgeError::from_display)?;
let open_failures: Vec<String> = failures let open_failures: Vec<String> = failures
@@ -1923,6 +2321,27 @@ impl AndroidEngine {
share_id: &str, share_id: &str,
uid: &NodeUid, uid: &NodeUid,
) -> Result<VideoStream, BridgeError> { ) -> Result<VideoStream, BridgeError> {
self.open_reauthenticating(connection, share_id, uid, false)
.await
}
/// [`Self::open_from_source`], or with `copy` the stream an offline
/// download reads: no read-ahead, nothing added to the memory ring or the
/// disk cache. See [`StreamSource::open_for_copy`].
async fn open_reauthenticating(
&self,
connection: &Connection,
share_id: &str,
uid: &NodeUid,
copy: bool,
) -> Result<VideoStream, BridgeError> {
let open = || async {
if copy {
connection.source.open_for_copy(share_id, uid).await
} else {
connection.source.open(share_id, uid).await
}
};
let stale = self let stale = self
.session_refreshed .session_refreshed
.lock() .lock()
@@ -1931,15 +2350,11 @@ impl AndroidEngine {
if stale { if stale {
self.refresh_session(connection, share_id).await; self.refresh_session(connection, share_id).await;
} }
match connection.source.open(share_id, uid).await { match open().await {
Ok(stream) => Ok(stream), Ok(stream) => Ok(stream),
Err(_) if !stale => { Err(_) if !stale => {
self.refresh_session(connection, share_id).await; self.refresh_session(connection, share_id).await;
connection open().await.map_err(BridgeError::from_display)
.source
.open(share_id, uid)
.await
.map_err(BridgeError::from_display)
} }
Err(error) => Err(BridgeError::from_display(error)), Err(error) => Err(BridgeError::from_display(error)),
} }
@@ -2071,9 +2486,26 @@ impl AndroidEngine {
return Ok(record); return Ok(record);
} }
// Nothing is reported until a slot is held, so the worker's record
// stays queued while it waits rather than claiming to be running.
let _permit = loop {
if cancelled(observer.as_ref()) {
return Err(BridgeError::Failure {
reason: "offline download cancelled".to_owned(),
});
}
let permits = Arc::clone(&self.download_permits);
tokio::select! {
permit = permits.acquire_owned() => {
break permit.map_err(BridgeError::from_display)?;
}
() = tokio::time::sleep(DOWNLOAD_WAIT_POLL) => {}
}
};
let connection = self.connection().await?; let connection = self.connection().await?;
let stream = self let stream = self
.open_from_source(&connection, share_id, &node_uid(volume_id, link_id)) .open_reauthenticating(&connection, share_id, &node_uid(volume_id, link_id), true)
.await?; .await?;
let revision_id = stream.revision_id().to_owned(); let revision_id = stream.revision_id().to_owned();
let block_sizes = stream.block_sizes().to_vec(); let block_sizes = stream.block_sizes().to_vec();
@@ -2125,7 +2557,13 @@ impl AndroidEngine {
let mut output = prepare_partial_file(&temporary, offset).await?; let mut output = prepare_partial_file(&temporary, offset).await?;
report(observer.as_ref(), offset, total); report(observer.as_ref(), offset, total);
use futures::TryStreamExt as _;
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
// A few blocks in flight, written in order. One at a time the copy ran
// at one round trip per 4 MiB; through the playback stream it carried
// twelve blocks of read-ahead, so the first block — and with it the
// first progress report — waited on all twelve sharing the link.
let mut fetches = stream.blocks_in_order(block_index, COPY_BLOCKS_IN_FLIGHT);
while block_index < block_sizes.len() { while block_index < block_sizes.len() {
if cancelled(observer.as_ref()) { if cancelled(observer.as_ref()) {
output.sync_all().await.map_err(BridgeError::from_display)?; output.sync_all().await.map_err(BridgeError::from_display)?;
@@ -2134,10 +2572,13 @@ impl AndroidEngine {
}); });
} }
let size = block_sizes[block_index]; let size = block_sizes[block_index];
let bytes = stream let bytes = fetches
.read_range(offset, size) .try_next()
.await .await
.map_err(BridgeError::from_display)?; .map_err(BridgeError::from_display)?
.ok_or_else(|| BridgeError::Failure {
reason: format!("offline block {block_index} never arrived"),
})?;
if bytes.len() as u64 != size { if bytes.len() as u64 != size {
return Err(BridgeError::Failure { return Err(BridgeError::Failure {
reason: format!( reason: format!(
@@ -3368,11 +3809,12 @@ mod tests {
assert!(engine.remove_share(share.id.clone()).is_err()); assert!(engine.remove_share(share.id.clone()).is_err());
assert_eq!(engine.share_generation.load(Ordering::Acquire), 2); assert_eq!(engine.share_generation.load(Ordering::Acquire), 2);
assert!(engine.shares().expect("shares").is_empty()); assert!(engine.shares().expect("shares").is_empty());
// Kept, as through a recrawl: sync would bring it back regardless.
assert!( assert!(
engine engine
.watch_state(share.id, "episode".to_owned()) .watch_state(share.id, "episode".to_owned())
.expect("watch state") .expect("watch state")
.is_none() .is_some()
); );
drop(engine); drop(engine);
+5
View File
@@ -33,6 +33,11 @@ serde_json.workspace = true
directories.workspace = true directories.workspace = true
chrono.workspace = true chrono.workspace = true
souvlaki.workspace = true souvlaki.workspace = true
wry.workspace = true
tao.workspace = true
[build-dependencies]
embed-resource.workspace = true
[dev-dependencies] [dev-dependencies]
skrifa.workspace = true skrifa.workspace = true
+15
View File
@@ -0,0 +1,15 @@
//! Compiles `proton-stream.rc` into the Windows exe; nothing on any other
//! target. `rc.exe` on an MSVC build, mingw's `windres` on the cross build
//! from Linux.
fn main() {
println!("cargo:rerun-if-changed=proton-stream.rc");
println!("cargo:rerun-if-changed=../../packaging/windows/proton-stream.ico");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
// Only the GUI gets the icon; `pstr` is a console tool.
embed_resource::compile_for("proton-stream.rc", ["proton-stream"], embed_resource::NONE)
.manifest_optional()
.expect("compile the icon resource");
}
+4
View File
@@ -0,0 +1,4 @@
// The exe's icon. The window sets one at runtime too, but that only lasts as
// long as the window: Explorer, the Start menu and a taskbar pin read the icon
// out of the file, and without this one they fall back to the generic one.
1 ICON "../../packaging/windows/proton-stream.ico"
+65 -2
View File
@@ -15,7 +15,8 @@ use pstr_core::library::Library;
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, TitleNames}; use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, TitleNames};
use crate::engine::{ use crate::engine::{
DownloadItem, DownloadKey, Engine, Event, ImageCache, describe_failures, watch_state, AccountStatus, DownloadItem, DownloadKey, Engine, Event, ImageCache, describe_failures,
watch_state,
}; };
use crate::pacing::Pacer; use crate::pacing::Pacer;
use crate::playback::{Playback, PlaybackTarget}; use crate::playback::{Playback, PlaybackTarget};
@@ -126,6 +127,25 @@ pub enum Action {
url: String, url: String,
password: Option<String>, password: Option<String>,
}, },
/// Add a folder of the signed-in account as a share.
AddAccountFolder {
name: String,
uid: pstr_core::proton_sdk::ids::NodeUid,
},
SignIn {
username: String,
password: String,
},
SubmitSecondFactor(String),
SubmitMailboxPassword(String),
CancelSignIn,
SignOut,
/// Sync watch history with the account's Drive now.
SyncNow,
/// List the account's top-level places in the Drive browser.
BrowsePlaces,
/// List one folder in the Drive browser.
BrowseFolder(pstr_core::proton_sdk::ids::NodeUid),
/// Ask whether to forget a share. [`Action::ForgetShare`] does it. /// Ask whether to forget a share. [`Action::ForgetShare`] does it.
RemoveShare(String), RemoveShare(String),
ForgetShare(String), ForgetShare(String),
@@ -535,6 +555,8 @@ pub struct App {
/// library means "not loaded", not "nothing in it". /// library means "not loaded", not "nothing in it".
pub loaded: bool, pub loaded: bool,
pub form: ShareForm, pub form: ShareForm,
/// The Proton account section of the shares page.
pub account: ui::account::AccountPanel,
pub playback: Option<Playback>, pub playback: Option<Playback>,
/// Whether the player page's controls are showing, and why. /// Whether the player page's controls are showing, and why.
pub overlay: ui::player::Overlay, pub overlay: ui::player::Overlay,
@@ -630,6 +652,7 @@ impl App {
view: LibraryView::default(), view: LibraryView::default(),
loaded: false, loaded: false,
form: ShareForm::default(), form: ShareForm::default(),
account: ui::account::AccountPanel::default(),
playback: None, playback: None,
overlay: ui::player::Overlay::default(), overlay: ui::player::Overlay::default(),
up_next: UpNext::default(), up_next: UpNext::default(),
@@ -882,6 +905,9 @@ impl App {
None => Page::Library, None => Page::Library,
}; };
} }
// Where it stopped is the position another device
// wants next, so it goes now rather than on the timer.
self.engine.sync_watch_history();
} }
} }
Event::Watched { Event::Watched {
@@ -905,6 +931,23 @@ impl App {
} }
} }
Event::OfflineFiles(files) => self.offline_files = files, Event::OfflineFiles(files) => self.offline_files = files,
Event::Account(status) => {
if let AccountStatus::SignedIn(username) = &status
&& self.account.busy
{
self.note(ctx, format!("signed in as {username}"), false);
}
self.account.set_status(status);
}
Event::SignInFailed(error) => self.account.refused(error),
Event::Synced(report) => {
self.account.sync_error = None;
self.account.synced = Some((ui::account::unix_now(), report));
}
Event::SyncFailed(error) => self.account.sync_error = Some(error),
Event::Places(places) => self.account.browser.places_arrived(places),
Event::Folder { uid, entries } => self.account.browser.folder_arrived(uid, entries),
Event::BrowseFailed(error) => self.account.browser.failed(error),
} }
} }
// Pictures become textures a few per frame; the rest wait for the next. // Pictures become textures a few per frame; the rest wait for the next.
@@ -1369,6 +1412,20 @@ impl App {
self.form.error = None; self.form.error = None;
self.engine.add_share(name, url, password); self.engine.add_share(name, url, password);
} }
Action::AddAccountFolder { name, uid } => {
self.crawling = true;
self.engine.add_account_folder(name, uid);
}
Action::SignIn { username, password } => self.engine.sign_in(username, password),
Action::SubmitSecondFactor(code) => self.engine.submit_second_factor(code),
Action::SubmitMailboxPassword(password) => {
self.engine.submit_mailbox_password(password)
}
Action::CancelSignIn => self.engine.cancel_sign_in(),
Action::SignOut => self.engine.sign_out(),
Action::SyncNow => self.engine.sync_watch_history(),
Action::BrowsePlaces => self.engine.browse_places(),
Action::BrowseFolder(uid) => self.engine.browse_folder(uid),
Action::RemoveShare(id) => self.confirm_remove_share = Some(id), Action::RemoveShare(id) => self.confirm_remove_share = Some(id),
Action::ForgetShare(id) => { Action::ForgetShare(id) => {
self.confirm_remove_share = None; self.confirm_remove_share = None;
@@ -1868,6 +1925,7 @@ impl eframe::App for App {
view, view,
loaded, loaded,
form, form,
account,
matcher, matcher,
playback, playback,
opening, opening,
@@ -1964,7 +2022,9 @@ impl eframe::App for App {
}, },
&mut actions, &mut actions,
), ),
Page::Shares => ui::shares::show(ui, shares, library, form, &mut actions), Page::Shares => {
ui::shares::show(ui, shares, library, form, account, &mut actions)
}
Page::Settings => { Page::Settings => {
ui::settings::show(ui, settings, prefs, api_key, &mut actions) ui::settings::show(ui, settings, prefs, api_key, &mut actions)
} }
@@ -2058,6 +2118,9 @@ impl eframe::App for App {
self.playback = None; self.playback = None;
self.engine self.engine
.shutdown_downloads(std::time::Duration::from_secs(3)); .shutdown_downloads(std::time::Duration::from_secs(3));
// After the player: its last position is saved on the way out.
self.engine
.sync_before_exit(std::time::Duration::from_secs(3));
} }
} }
+350 -47
View File
@@ -25,6 +25,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel}; use std::sync::mpsc::{Receiver, Sender, channel};
use parking_lot::Mutex; use parking_lot::Mutex;
use pstr_core::account::{DriveEntry, DrivePlace, PendingSignIn, SignIn};
use pstr_core::appearance::Appearance; use pstr_core::appearance::Appearance;
use pstr_core::catalog::{ use pstr_core::catalog::{
Catalog, CatalogNode, OfflineFile, TitleTrackPrefs, WatchState, build_rows, Catalog, CatalogNode, OfflineFile, TitleTrackPrefs, WatchState, build_rows,
@@ -34,14 +35,17 @@ use pstr_core::library::{Library, Title, TitleKind};
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId}; use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId};
use pstr_core::prefs::PlaybackPrefs; use pstr_core::prefs::PlaybackPrefs;
use pstr_core::proton_drive_rs::ThumbnailType; use pstr_core::proton_drive_rs::ThumbnailType;
use pstr_core::proton_sdk::api::HumanVerificationCredential;
use pstr_core::proton_sdk::ids::{LinkId, NodeUid, VolumeId}; use pstr_core::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use pstr_core::{Share, ShareStore, SharedLibrary}; use pstr_core::shares::AccountFolder;
use pstr_core::sync::{SyncReport, WatchSync};
use pstr_core::{Account, AccountStore, KeyringSecretStore, Share, ShareStore, SharedLibrary};
use pstr_meta::MetadataService; use pstr_meta::MetadataService;
use crate::desktop_prefs::{DesktopPrefs, DesktopPrefsFile}; use crate::desktop_prefs::{DesktopPrefs, DesktopPrefsFile};
use pstr_stream::{ use pstr_stream::{
BlockSource, DiskCacheConfig, FileBlocks, LibraryOpener, StreamConfig, StreamSource, BlockSource, COPY_BLOCKS_IN_FLIGHT, DiskCacheConfig, FileBlocks, LibraryOpener, StreamConfig,
VideoStream, StreamSource, VideoStream,
}; };
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
@@ -269,6 +273,38 @@ pub enum Event {
DownloadProgress(Box<DownloadItem>), DownloadProgress(Box<DownloadItem>),
/// Completed local copies, for badges and online-only actions. /// Completed local copies, for badges and online-only actions.
OfflineFiles(HashSet<DownloadKey>), OfflineFiles(HashSet<DownloadKey>),
/// The Proton account signed in or out, or a sign-in wants more.
Account(AccountStatus),
/// A sign-in step was refused, with why.
SignInFailed(String),
/// Watch history synced with the account's Drive.
Synced(SyncReport),
/// Watch history did not sync, with why. Not a toast: it is retried on a
/// timer, and an offline evening would be a stack of them.
SyncFailed(String),
/// The account's top-level places, for the Drive browser.
Places(Vec<DrivePlace>),
/// One folder of the account, for the Drive browser.
Folder {
uid: NodeUid,
entries: Vec<DriveEntry>,
},
/// The Drive browser's last request failed.
BrowseFailed(String),
}
/// Where the Proton account stands.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum AccountStatus {
/// The stored session has not been read yet.
#[default]
Unknown,
SignedOut,
SignedIn(String),
/// A sign-in is waiting for a second-factor code.
SecondFactor,
/// A sign-in is waiting for the mailbox password.
MailboxPassword,
} }
/// The share clients and the block layer, once they exist. /// The share clients and the block layer, once they exist.
@@ -335,6 +371,17 @@ pub struct Engine {
desktop_file: Arc<DesktopPrefsFile>, desktop_file: Arc<DesktopPrefsFile>,
/// Bumped to stop the crawls in flight; each watches it from its start. /// Bumped to stop the crawls in flight; each watches it from its start.
crawl_cancel: Arc<tokio::sync::watch::Sender<u64>>, crawl_cancel: Arc<tokio::sync::watch::Sender<u64>>,
accounts: AccountStore,
/// The signed-in Proton account, if any.
account: Arc<Mutex<Option<Account>>>,
/// False until the stored session has been read. Opening the shares waits
/// on it, or a launch would open the account's folders before the account
/// and report every one of them as failing to.
account_settled: Arc<tokio::sync::watch::Sender<bool>>,
/// A sign-in waiting for a code or the mailbox password.
pending_sign_in: Arc<Mutex<Option<PendingSignIn>>>,
/// `None` when this installation's sync identity could not be written.
watch_sync: Option<Arc<WatchSync>>,
events: Sender<Event>, events: Sender<Event>,
ctx: egui::Context, ctx: egui::Context,
} }
@@ -345,10 +392,10 @@ const THUMBNAIL_CONCURRENCY: usize = 6;
/// How many provider lookups may be in flight at once. See [`Engine::lookups`]. /// How many provider lookups may be in flight at once. See [`Engine::lookups`].
const LOOKUP_CONCURRENCY: usize = 2; const LOOKUP_CONCURRENCY: usize = 2;
const DOWNLOAD_CONCURRENCY: usize = 3; const DOWNLOAD_CONCURRENCY: usize = 3;
/// How many blocks of one download are fetched at once. Four is what the
/// stream layer itself allows a single read, for the same reason: enough to /// How often watch history is synced while the app is open, on top of at
/// hide a round trip, few enough that three downloads do not swamp a player. /// launch and whenever a player stops.
const DOWNLOAD_BLOCKS_IN_FLIGHT: usize = 4; const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5 * 60);
/// Enrichment as currently configured. /// Enrichment as currently configured.
struct Enrichment { struct Enrichment {
@@ -427,6 +474,11 @@ impl Engine {
Enrichment::build(config.clone()) Enrichment::build(config.clone())
}; };
let watch_sync = WatchSync::open(&dirs)
.inspect_err(|error| tracing::warn!("watch-history sync unavailable: {error}"))
.ok()
.map(Arc::new);
let track_prefs = catalog.all_title_track_prefs().unwrap_or_else(|error| { let track_prefs = catalog.all_title_track_prefs().unwrap_or_else(|error| {
tracing::warn!("read show track preferences: {error}"); tracing::warn!("read show track preferences: {error}");
HashMap::new() HashMap::new()
@@ -449,6 +501,11 @@ impl Engine {
download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)), download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)),
picture_shown: Arc::new(AtomicBool::new(false)), picture_shown: Arc::new(AtomicBool::new(false)),
crawl_cancel: Arc::new(tokio::sync::watch::channel(0).0), crawl_cancel: Arc::new(tokio::sync::watch::channel(0).0),
accounts: AccountStore::new(Arc::new(KeyringSecretStore)),
account: Arc::new(Mutex::new(None)),
account_settled: Arc::new(tokio::sync::watch::channel(false).0),
pending_sign_in: Arc::new(Mutex::new(None)),
watch_sync,
desktop: Arc::new(Mutex::new(desktop)), desktop: Arc::new(Mutex::new(desktop)),
desktop_file: Arc::new(desktop_file), desktop_file: Arc::new(desktop_file),
events, events,
@@ -460,6 +517,8 @@ impl Engine {
deferred.install_enrichment(Enrichment::build(config), true); deferred.install_enrichment(Enrichment::build(config), true);
}); });
} }
engine.load_account();
engine.sync_periodically();
Ok((engine, receiver)) Ok((engine, receiver))
} }
@@ -591,10 +650,281 @@ impl Engine {
}; };
engine.emit(Event::ShareAdded(share.name.clone())); engine.emit(Event::ShareAdded(share.name.clone()));
engine.load_shares(); engine.load_shares();
// The open connection predates the share, and crawling it through
// that would find it "not open".
engine.connection.lock().take();
engine.connect_and_crawl(Some(share.id)).await; engine.connect_and_crawl(Some(share.id)).await;
}); });
} }
/// Add a folder of the signed-in account as a share, then crawl it.
pub fn add_account_folder(&self, name: String, folder: NodeUid) {
let engine = self.clone();
self.runtime.spawn(async move {
let folder = AccountFolder {
volume_id: folder.volume_id.as_str().to_owned(),
link_id: folder.link_id.as_str().to_owned(),
};
let share = match engine.store.add_folder(&name, folder) {
Ok(share) => share,
Err(error) => return engine.emit(Event::ShareRejected(error.to_string())),
};
engine.emit(Event::ShareAdded(share.name.clone()));
engine.load_shares();
engine.connection.lock().take();
engine.connect_and_crawl(Some(share.id)).await;
});
}
// --------------------------------------------------------------- account
/// Read the stored session, off the UI thread — a keyring read can be a
/// D-Bus round trip to a keyring that has to unlock first.
fn load_account(&self) {
let engine = self.clone();
self.runtime.spawn_blocking(move || {
match engine.accounts.resume() {
Ok(Some(account)) => {
let username = account.username().to_owned();
*engine.account.lock() = Some(account);
engine.emit(Event::Account(AccountStatus::SignedIn(username)));
engine.sync_watch_history();
}
Ok(None) => engine.emit(Event::Account(AccountStatus::SignedOut)),
Err(error) => {
engine.emit(Event::Account(AccountStatus::SignedOut));
engine.fail("read the Proton session", error);
}
}
engine.account_settled.send_replace(true);
});
}
/// Start signing in. What happens next arrives as [`Event::Account`] or
/// [`Event::SignInFailed`].
pub fn sign_in(&self, username: String, password: String) {
let engine = self.clone();
self.runtime.spawn(async move {
let mut verification = None;
// Twice at most: once as asked, and once more with a solved
// CAPTCHA if Proton wanted one. A second gate after that is
// Proton refusing the answer, not a puzzle worth repeating.
for _ in 0..2 {
match engine
.accounts
.sign_in(&username, &password, verification.take())
.await
{
Ok(step) => return engine.sign_in_step(step),
Err(pstr_core::Error::HumanVerification(challenge)) => {
let url = challenge.verification_url();
let token = tokio::task::spawn_blocking(move || crate::verify::solve(&url))
.await
.ok()
.flatten();
let Some(token) = token else {
return engine.emit(Event::SignInFailed(
"Proton wants you to confirm you are human before it accepts \
this sign-in. Sign in again to solve the puzzle."
.to_owned(),
));
};
verification = Some(HumanVerificationCredential::captcha(token));
}
Err(error) => return engine.emit(Event::SignInFailed(error.to_string())),
}
}
engine.emit(Event::SignInFailed(
"Proton did not accept the verification. Try again in a while.".to_owned(),
));
});
}
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()));
}
}
});
}
/// Sync once more before the app exits, waiting at most `timeout`.
///
/// Where the viewer stopped is what another device wants next, and the
/// timer would not get to it. Bounded, because quitting must not hang on
/// a slow network; a sync cut short loses nothing, the next launch sends
/// the same table.
pub fn sync_before_exit(&self, timeout: std::time::Duration) {
let (Some(account), Some(sync)) = (self.account.lock().clone(), self.watch_sync.clone())
else {
return;
};
let catalog = Arc::clone(&self.catalog);
self.runtime.block_on(async move {
match tokio::time::timeout(timeout, sync.sync(&account, &catalog)).await {
Ok(Ok(_)) => {}
Ok(Err(error)) => tracing::warn!("sync watch history on exit: {error}"),
Err(_) => tracing::warn!("sync watch history on exit: timed out"),
}
});
}
fn sync_periodically(&self) {
let engine = self.clone();
self.runtime.spawn(async move {
let mut ticks = tokio::time::interval(SYNC_INTERVAL);
// The first tick is immediate, and launch already syncs.
ticks.tick().await;
loop {
ticks.tick().await;
engine.sync_watch_history();
}
});
}
/// List the account's top-level places, for the Drive browser.
pub fn browse_places(&self) {
let Some(account) = self.account.lock().clone() else {
return self.emit(Event::BrowseFailed("not signed in".to_owned()));
};
let engine = self.clone();
self.runtime.spawn(async move {
match account.places().await {
Ok(places) => engine.emit(Event::Places(places)),
Err(error) => engine.emit(Event::BrowseFailed(error.to_string())),
}
});
}
/// List one folder of the account, for the Drive browser.
pub fn browse_folder(&self, uid: NodeUid) {
let Some(account) = self.account.lock().clone() else {
return self.emit(Event::BrowseFailed("not signed in".to_owned()));
};
let engine = self.clone();
self.runtime.spawn(async move {
match account.folder_children(&uid).await {
Ok(entries) => engine.emit(Event::Folder { uid, entries }),
Err(error) => engine.emit(Event::BrowseFailed(error.to_string())),
}
});
}
/// Forget a share and its stored secrets. /// Forget a share and its stored secrets.
pub fn remove_share(&self, id: String) { pub fn remove_share(&self, id: String) {
let engine = self.clone(); let engine = self.clone();
@@ -696,7 +1026,11 @@ impl Engine {
return Some(opened); return Some(opened);
} }
let (library, failures) = match SharedLibrary::open_all(&self.store).await { let mut settled = self.account_settled.subscribe();
let _ = settled.wait_for(|settled| *settled).await;
let account = self.account.lock().clone();
let (library, failures) = match SharedLibrary::open_all(&self.store, account.as_ref()).await
{
Ok(opened) => opened, Ok(opened) => opened,
Err(error) => { Err(error) => {
self.emit(Event::ConnectFailed(error.to_string())); self.emit(Event::ConnectFailed(error.to_string()));
@@ -1555,8 +1889,8 @@ impl Engine {
return Ok(DownloadEnd::Cancelled); return Ok(DownloadEnd::Cancelled);
} }
let size = block_sizes[block_index]; let size = block_sizes[block_index];
let pending = let pending = fetches
fetches.get_or_insert_with(|| fetch_in_order(&stream, &block_sizes, block_index)); .get_or_insert_with(|| stream.blocks_in_order(block_index, COPY_BLOCKS_IN_FLIGHT));
let Some(bytes) = pending.try_next().await? else { let Some(bytes) = pending.try_next().await? else {
anyhow::bail!("block {block_index} never arrived"); anyhow::bail!("block {block_index} never arrived");
}; };
@@ -2043,27 +2377,25 @@ impl Engine {
return engine.emit(Event::Error(engine.no_service_reason())); return engine.emit(Event::Error(engine.no_service_reason()));
}; };
let name = found.name.clone(); let name = found.name.clone();
let record = service.chosen(title.key.clone(), found.clone()); let outcome = service.choose(&title, found).await;
if let Err(error) = engine.catalog.lock().set_metadata(&record) { if let Some(record) = &outcome.record
&& let Err(error) = engine.catalog.lock().set_metadata(record)
{
return engine.fail("store the match", error); return engine.fail("store the match", error);
} }
// A failure leaves the title un-enriched, and the next match run // A failure leaves the title un-enriched, and the next match run
// asks again. // asks again.
match service.enrich(&title, &found).await { if let Some(enrichment) = &outcome.enrichment {
Ok(enrichment) => {
let stored = engine.catalog.lock().set_enrichment( let stored = engine.catalog.lock().set_enrichment(
&title.key, &title.key,
service.provider(), service.provider(),
now(), now(),
&enrichment, enrichment,
); );
if let Err(error) = stored { if let Err(error) = stored {
tracing::warn!("store enrichment for {}: {error}", title.key); tracing::warn!("store enrichment for {}: {error}", title.key);
} }
} }
Err(error) => tracing::warn!("enrich {}: {error}", title.key),
}
engine.emit(Event::Status(format!("{} is now {name}", title.name))); engine.emit(Event::Status(format!("{} is now {name}", title.name)));
engine.load_metadata(); engine.load_metadata();
@@ -2223,35 +2555,6 @@ fn resume_position(existing: u64, block_sizes: &[u64]) -> (usize, u64) {
(block_sizes.len(), offset) (block_sizes.len(), offset)
} }
/// A file's blocks from `first` on, fetched several at a time and yielded in
/// file order — which is what the resume marker's "the length is the
/// boundary" rule needs. One at a time, a download runs at one block per round
/// trip whatever the link can carry.
fn fetch_in_order(
stream: &VideoStream,
block_sizes: &[u64],
first: usize,
) -> futures::stream::BoxStream<'static, pstr_stream::Result<Vec<u8>>> {
use futures::StreamExt as _;
let start: u64 = block_sizes[..first].iter().sum();
let ranges: Vec<(u64, u64)> = block_sizes[first..]
.iter()
.scan(start, |offset, size| {
let range = (*offset, *size);
*offset += size;
Some(range)
})
.collect();
let stream = stream.clone();
futures::stream::iter(ranges)
.map(move |(offset, size)| {
let stream = stream.clone();
async move { stream.read_range(offset, size).await }
})
.buffered(DOWNLOAD_BLOCKS_IN_FLIGHT)
.boxed()
}
async fn wait_until_runnable(command: &mut tokio::sync::watch::Receiver<DownloadCommand>) -> bool { async fn wait_until_runnable(command: &mut tokio::sync::watch::Receiver<DownloadCommand>) -> bool {
loop { loop {
let current = *command.borrow_and_update(); let current = *command.borrow_and_update();
+1
View File
@@ -30,5 +30,6 @@ pub mod pacing;
pub mod playback; pub mod playback;
pub mod theme; pub mod theme;
pub mod ui; pub mod ui;
pub mod verify;
pub mod video; pub mod video;
pub mod window; pub mod window;
+6
View File
@@ -16,6 +16,12 @@ use anyhow::Context;
use pstr_core::config::AppDirs; use pstr_core::config::AppDirs;
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
// Before anything else: a verification window is a child process of the
// app, and must not claim the single instance or open a library.
if let Some(code) = pstr_app::verify::run_if_requested() {
std::process::exit(code);
}
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env() tracing_subscriber::EnvFilter::try_from_default_env()
+602
View File
@@ -0,0 +1,602 @@
//! The Proton account, on the shares page: signing in, watch-history sync, and
//! picking folders of the viewer's own Drive as library shares.
//!
//! Optional and said so: public links need no account, and the page reads the
//! same without one. What signing in adds is spelled out next to the form,
//! because "sign in" alone does not say why an app that streams public links
//! would want a password.
use pstr_core::account::{DriveEntry, DrivePlace, PlaceKind};
use pstr_core::proton_sdk::ids::NodeUid;
use pstr_core::sync::SyncReport;
use crate::app::Action;
use crate::engine::AccountStatus;
use crate::theme;
use crate::ui;
/// What the account section holds between frames.
#[derive(Default)]
pub struct AccountPanel {
pub status: AccountStatus,
pub username: String,
pub password: String,
pub code: String,
pub mailbox: String,
/// A request is out and not answered yet.
pub busy: bool,
/// Why the last sign-in step was refused.
pub error: Option<String>,
/// When watch history last synced, in Unix seconds, and what it did.
pub synced: Option<(i64, SyncReport)>,
/// Why the last sync failed, until one succeeds.
pub sync_error: Option<String>,
pub browser: DriveBrowser,
}
impl AccountPanel {
/// Apply where the account now stands. The typed secrets go as soon as
/// they are not needed: a password has no business sitting in a struct for
/// the rest of the session.
pub fn set_status(&mut self, status: AccountStatus) {
self.busy = false;
match &status {
AccountStatus::SignedIn(_) | AccountStatus::SignedOut => {
self.password.clear();
self.code.clear();
self.mailbox.clear();
if matches!(status, AccountStatus::SignedIn(_)) {
self.error = None;
} else {
self.browser = DriveBrowser::default();
self.synced = None;
self.sync_error = None;
}
}
AccountStatus::SecondFactor => self.error = None,
AccountStatus::MailboxPassword => {
self.code.clear();
self.error = None;
}
AccountStatus::Unknown => {}
}
self.status = status;
}
pub fn refused(&mut self, error: String) {
self.busy = false;
self.error = Some(error);
}
}
/// Where the Drive browser is.
#[derive(Default)]
pub struct DriveBrowser {
pub open: bool,
/// The folders descended through, each by name. Empty means the places.
pub trail: Vec<(String, NodeUid)>,
pub places: Option<Vec<DrivePlace>>,
/// The listing of the folder at the end of the trail, once it arrives.
pub entries: Option<(NodeUid, Vec<DriveEntry>)>,
pub loading: bool,
pub error: Option<String>,
}
impl DriveBrowser {
pub fn places_arrived(&mut self, places: Vec<DrivePlace>) {
self.loading = false;
self.error = None;
self.places = Some(places);
}
/// Take a listing, unless the viewer has already moved on from it.
pub fn folder_arrived(&mut self, uid: NodeUid, entries: Vec<DriveEntry>) {
if self
.trail
.last()
.is_some_and(|(_, current)| *current == uid)
{
self.loading = false;
self.error = None;
self.entries = Some((uid, entries));
}
}
pub fn failed(&mut self, error: String) {
self.loading = false;
self.error = Some(error);
}
fn enter(&mut self, name: String, uid: NodeUid, actions: &mut Vec<Action>) {
self.trail.push((name, uid.clone()));
self.entries = None;
self.loading = true;
self.error = None;
actions.push(Action::BrowseFolder(uid));
}
fn up(&mut self, actions: &mut Vec<Action>) {
self.trail.pop();
self.entries = None;
self.error = None;
match self.trail.last() {
Some((_, uid)) => {
self.loading = true;
actions.push(Action::BrowseFolder(uid.clone()));
}
None => self.loading = self.places.is_none(),
}
}
}
/// The account card: a sign-in form, or who is signed in and how sync is doing.
pub fn section(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
ui::section(ui, "Proton account");
ui.label(ui::muted(
"Optional. Signed in, your watch history is kept in your own Drive and every device \
resumes where another left off, and folders of your Drive — your files, other \
devices, what was shared with you — can be added to the library.",
));
ui.add_space(theme::space::L);
card(ui, |ui| match panel.status.clone() {
AccountStatus::Unknown => {
ui.horizontal(|ui| {
ui.add(egui::Spinner::new().size(14.0));
ui.label(ui::muted("reading the stored session…"));
});
}
AccountStatus::SignedOut => sign_in_form(ui, panel, actions),
AccountStatus::SecondFactor => code_form(ui, panel, actions),
AccountStatus::MailboxPassword => mailbox_form(ui, panel, actions),
AccountStatus::SignedIn(username) => signed_in(ui, panel, &username, actions),
});
}
fn card(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) {
egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(theme::radius::MD))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
ui.set_width(ui.available_width().min(640.0));
add(ui);
});
}
fn sign_in_form(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
ui.label(ui::muted("Address"));
ui.add(
egui::TextEdit::singleline(&mut panel.username)
.hint_text("you@proton.me")
.desired_width(f32::INFINITY),
);
ui.add_space(theme::space::M);
ui.label(ui::muted("Password"));
let field = ui.add(
egui::TextEdit::singleline(&mut panel.password)
.password(true)
.desired_width(f32::INFINITY),
);
ui.add_space(theme::space::L);
error_line(ui, panel);
let ready = !panel.username.trim().is_empty() && !panel.password.is_empty() && !panel.busy;
let submitted = field.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
ui.horizontal(|ui| {
let clicked = ui
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Sign in"))
.inner
.clicked();
if ready && (clicked || submitted) {
panel.busy = true;
panel.error = None;
actions.push(Action::SignIn {
username: panel.username.trim().to_owned(),
password: panel.password.clone(),
});
}
busy_line(ui, panel, "signing in…");
});
}
fn code_form(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
ui.label(ui::muted("Two-factor code from your authenticator app"));
let field = ui.add(
egui::TextEdit::singleline(&mut panel.code)
.hint_text("123456")
.desired_width(160.0),
);
field.request_focus();
ui.add_space(theme::space::S);
// Security keys need WebAuthn, which the sign-in here does not speak. An
// account with nothing else is refused before this form (`AccountStore`).
ui.label(ui::muted(
"Security keys are not supported here — use the code from your authenticator app.",
));
ui.add_space(theme::space::L);
error_line(ui, panel);
let ready = !panel.code.trim().is_empty() && !panel.busy;
let submitted = field.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
ui.horizontal(|ui| {
let clicked = ui
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Continue"))
.inner
.clicked();
if ready && (clicked || submitted) {
panel.busy = true;
actions.push(Action::SubmitSecondFactor(panel.code.trim().to_owned()));
}
if ui.button("Cancel").clicked() {
actions.push(Action::CancelSignIn);
}
busy_line(ui, panel, "checking…");
});
}
fn mailbox_form(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Action>) {
ui.label(ui::muted(
"This account has a separate mailbox password. It unlocks your files, and is kept \
in your system keyring.",
));
ui.add_space(theme::space::S);
let field = ui.add(
egui::TextEdit::singleline(&mut panel.mailbox)
.password(true)
.hint_text("Mailbox password")
.desired_width(f32::INFINITY),
);
ui.add_space(theme::space::L);
error_line(ui, panel);
let ready = !panel.mailbox.is_empty() && !panel.busy;
let submitted = field.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
ui.horizontal(|ui| {
let clicked = ui
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Unlock"))
.inner
.clicked();
if ready && (clicked || submitted) {
panel.busy = true;
actions.push(Action::SubmitMailboxPassword(panel.mailbox.clone()));
}
if ui.button("Cancel").clicked() {
actions.push(Action::CancelSignIn);
}
busy_line(ui, panel, "unlocking…");
});
}
fn signed_in(
ui: &mut egui::Ui,
panel: &mut AccountPanel,
username: &str,
actions: &mut Vec<Action>,
) {
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label(
egui::RichText::new(format!(
"{} {username}",
egui_phosphor::regular::USER_CIRCLE
))
.strong(),
);
let status = match (&panel.sync_error, &panel.synced) {
(Some(error), _) => format!("Watch history did not sync: {error}"),
(None, Some((at, report))) => sync_line(*at, report, unix_now()),
(None, None) => "Watch history syncs with your Drive".to_owned(),
};
ui.label(ui::muted(status));
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(
egui::Button::new(format!("{} Sign out", egui_phosphor::regular::SIGN_OUT))
.fill(theme::card_hover()),
)
.clicked()
{
actions.push(Action::SignOut);
}
if ui
.button(format!(
"{} Sync now",
egui_phosphor::regular::ARROWS_CLOCKWISE
))
.clicked()
{
actions.push(Action::SyncNow);
}
});
});
}
/// "Synced 3 min ago · 2 positions from other devices".
fn sync_line(at: i64, report: &SyncReport, now: i64) -> String {
let ago = (now - at).max(0);
let when = match ago {
0..60 => "just now".to_owned(),
60..3600 => format!("{} min ago", ago / 60),
_ => format!("{} h ago", ago / 3600),
};
let mut line = format!("Synced {when}");
if report.applied > 0 {
line.push_str(&format!(
" · {} from other devices",
ui::library::plural(report.applied, "position")
));
}
line
}
fn error_line(ui: &mut egui::Ui, panel: &AccountPanel) {
if let Some(error) = &panel.error {
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
ui.add_space(theme::space::S);
}
}
fn busy_line(ui: &mut egui::Ui, panel: &AccountPanel, text: &str) {
if panel.busy {
ui.add(egui::Spinner::new().size(14.0));
ui.label(ui::muted(text));
}
}
/// Browse the account's Drive and add a folder of it to the library.
pub fn browser(
ui: &mut egui::Ui,
panel: &mut AccountPanel,
shares: &[pstr_core::Share],
actions: &mut Vec<Action>,
) {
if !matches!(panel.status, AccountStatus::SignedIn(_)) {
return;
}
ui.add_space(theme::space::XXL);
ui::section(ui, "Add from your Drive");
let browser = &mut panel.browser;
if !browser.open {
ui.label(ui::muted(
"A folder of your own Drive becomes a share like any link: crawled, browsable and \
streamed through your account.",
));
ui.add_space(theme::space::L);
if ui
.button(format!(
"{} Browse your Drive",
egui_phosphor::regular::FOLDER
))
.clicked()
{
browser.open = true;
if browser.places.is_none() {
browser.loading = true;
actions.push(Action::BrowsePlaces);
}
}
return;
}
card(ui, |ui| {
ui.horizontal(|ui| {
if !browser.trail.is_empty()
&& ui
.button(egui_phosphor::regular::ARROW_LEFT)
.on_hover_text("Up a folder")
.clicked()
{
browser.up(actions);
}
let mut crumbs = vec!["Drive".to_owned()];
crumbs.extend(browser.trail.iter().map(|(name, _)| name.clone()));
ui.label(
egui::RichText::new(
crumbs.join(&format!(" {} ", egui_phosphor::regular::CARET_RIGHT)),
)
.strong(),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Close").clicked() {
browser.open = false;
}
});
});
ui.add_space(theme::space::M);
if let Some(error) = &browser.error {
ui.label(theme::Role::Caption.rich(error).color(theme::danger()));
ui.add_space(theme::space::S);
}
if browser.loading {
ui.horizontal(|ui| {
ui.add(egui::Spinner::new().size(14.0));
ui.label(ui::muted("listing…"));
});
return;
}
let mut entered = None;
egui::ScrollArea::vertical()
.max_height(320.0)
.auto_shrink([false, true])
.show(ui, |ui| match browser.trail.last() {
None => {
for place in browser.places.iter().flatten() {
let icon = match place.kind {
PlaceKind::MyFiles => egui_phosphor::regular::HARD_DRIVES,
PlaceKind::Device => egui_phosphor::regular::DESKTOP,
PlaceKind::SharedWithMe => egui_phosphor::regular::USERS,
};
if row(ui, icon, &place.name, None, true).clicked() {
entered = Some((place.name.clone(), place.uid.clone()));
}
}
}
Some(_) => {
let entries = browser
.entries
.as_ref()
.map(|(_, entries)| entries.as_slice());
if entries.is_some_and(<[DriveEntry]>::is_empty) {
ui.label(ui::muted("This folder is empty."));
}
for entry in entries.unwrap_or_default() {
let (icon, detail) = if entry.is_folder {
(egui_phosphor::regular::FOLDER, None)
} else if entry
.media_type
.as_deref()
.is_some_and(|media| media.starts_with("video/"))
{
(
egui_phosphor::regular::FILE_VIDEO,
entry.size.map(format_size),
)
} else {
(egui_phosphor::regular::FILE, entry.size.map(format_size))
};
if row(ui, icon, &entry.name, detail.as_deref(), entry.is_folder).clicked()
&& entry.is_folder
{
entered = Some((entry.name.clone(), entry.uid.clone()));
}
}
}
});
if let Some((name, uid)) = entered {
browser.enter(name, uid, actions);
}
if let Some((name, uid)) = browser.trail.last() {
ui.add_space(theme::space::L);
let added = shares.iter().any(|share| {
share
.folder
.as_ref()
.is_some_and(|folder| folder.uid() == *uid)
});
ui.horizontal(|ui| {
if added {
ui.label(ui::muted("This folder is already in the library."));
} else if ui::accent_button(ui, "Add this folder").clicked() {
actions.push(Action::AddAccountFolder {
name: name.clone(),
uid: uid.clone(),
});
browser.open = false;
}
});
}
});
}
/// One line of the browser. Only folders and places are clickable.
fn row(
ui: &mut egui::Ui,
icon: &str,
name: &str,
detail: Option<&str>,
clickable: bool,
) -> egui::Response {
let mut text = egui::text::LayoutJob::default();
let body = ui.style().text_styles[&egui::TextStyle::Body].clone();
text.append(
&format!("{icon} {name}"),
0.0,
egui::TextFormat::simple(
body.clone(),
if clickable {
theme::text()
} else {
theme::muted()
},
),
);
if let Some(detail) = detail {
text.append(
&format!(" {detail}"),
0.0,
egui::TextFormat::simple(body, theme::muted()),
);
}
let sense = if clickable {
egui::Sense::click()
} else {
egui::Sense::hover()
};
let response = ui.add(egui::Label::new(text).sense(sense).truncate());
if clickable && response.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
response
}
fn format_size(bytes: i64) -> String {
let bytes = bytes.max(0) as f64;
const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
const MIB: f64 = 1024.0 * 1024.0;
if bytes >= GIB {
format!("{:.1} GiB", bytes / GIB)
} else {
format!("{:.0} MiB", bytes / MIB)
}
}
pub(crate) fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|since| since.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_sync_line_says_how_long_ago_and_what_came_in() {
let quiet = SyncReport::default();
assert_eq!(sync_line(100, &quiet, 130), "Synced just now");
assert_eq!(sync_line(100, &quiet, 400), "Synced 5 min ago");
let busy = SyncReport {
applied: 2,
..SyncReport::default()
};
assert_eq!(
sync_line(0, &busy, 7200),
"Synced 2 h ago · 2 positions from other devices"
);
}
#[test]
fn signing_out_forgets_what_was_typed_and_browsed() {
let mut panel = AccountPanel {
password: "secret".into(),
mailbox: "also secret".into(),
..AccountPanel::default()
};
panel.browser.open = true;
panel.set_status(AccountStatus::SignedOut);
assert!(panel.password.is_empty() && panel.mailbox.is_empty());
assert!(!panel.browser.open);
}
#[test]
fn a_listing_for_a_folder_already_left_is_dropped() {
let uid = |link: &str| {
NodeUid::new(
pstr_core::proton_sdk::ids::VolumeId::new("v".to_owned()),
pstr_core::proton_sdk::ids::LinkId::new(link.to_owned()),
)
};
let mut browser = DriveBrowser {
trail: vec![("b".into(), uid("b"))],
loading: true,
..DriveBrowser::default()
};
browser.folder_arrived(uid("a"), Vec::new());
assert!(browser.loading && browser.entries.is_none());
browser.folder_arrived(uid("b"), Vec::new());
assert!(!browser.loading && browser.entries.is_some());
}
}
+1
View File
@@ -3,6 +3,7 @@
//! mutating the library it is iterating: pages collect [`Action`]s and the app //! mutating the library it is iterating: pages collect [`Action`]s and the app
//! applies them after the frame is drawn. //! applies them after the frame is drawn.
pub mod account;
pub mod downloads; pub mod downloads;
pub mod history; pub mod history;
pub mod library; pub mod library;
+9
View File
@@ -18,6 +18,7 @@ pub fn show(
shares: &[Share], shares: &[Share],
library: &Library, library: &Library,
form: &mut ShareForm, form: &mut ShareForm,
account: &mut ui::account::AccountPanel,
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
@@ -40,6 +41,10 @@ pub fn show(
ui.add_space(theme::space::XXL); ui.add_space(theme::space::XXL);
add_form(ui, shares, form, actions); add_form(ui, shares, form, actions);
ui.add_space(theme::space::XXL);
ui::account::section(ui, account, actions);
ui::account::browser(ui, account, shares, actions);
}); });
} }
@@ -56,6 +61,9 @@ fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec<
// What is in it, rather than what it is called inside the // What is in it, rather than what it is called inside the
// app. The id is still a hover away for a bug report. // app. The id is still a hover away for a bug report.
let mut detail = ui::library::plural(titles, "title"); let mut detail = ui::library::plural(titles, "title");
if share.folder.is_some() {
detail.push_str(" · from your Drive");
}
if share.has_custom_password { if share.has_custom_password {
detail.push_str(" · password protected"); detail.push_str(" · password protected");
} }
@@ -211,6 +219,7 @@ mod tests {
name: "anime".into(), name: "anime".into(),
token: token.into(), token: token.into(),
has_custom_password: false, has_custom_password: false,
folder: None,
} }
} }
+190
View File
@@ -0,0 +1,190 @@
//! Proton's CAPTCHA, in a window of its own.
//!
//! Proton puts a CAPTCHA in front of a sign-in it does not recognise — a new
//! network, a VPN exit. The page reports success by posting a message to its
//! host rather than by redirecting, so the system browser has nowhere to send
//! it back to: it needs a webview this app controls, as on Android and in
//! `proton-drive-linux`.
//!
//! The webview runs in a child process — this same executable, started with
//! [`ARG`] — because its windowing library and eframe's cannot both own the
//! event loop of one process. The child prints the solved token on stdout and
//! exits; closing its window exits with nothing printed, which is a cancel.
use std::io::{Read as _, Write as _};
/// The argument that starts this executable as the verification window,
/// followed by the page's URL.
pub const ARG: &str = "--human-verification";
/// Forwards every message the page posts to this process. The page targets
/// `window.parent`, which for a top-level page is the page itself, so a
/// listener on its own window hears it.
const BRIDGE: &str = r#"
window.addEventListener('message', function (event) {
try {
window.ipc.postMessage(typeof event.data === 'string' ? event.data : JSON.stringify(event.data));
} catch (e) {}
});
"#;
/// Run the verification window if this process was started for it, returning
/// the process's exit code; `None` when it was started as the app.
pub fn run_if_requested() -> Option<i32> {
let mut args = std::env::args().skip(1);
if args.next().as_deref() != Some(ARG) {
return None;
}
let Some(url) = args.next().filter(|url| is_proton_page(url)) else {
eprintln!("{ARG} needs a verification page on proton.me");
return Some(2);
};
Some(match window(&url) {
Ok(()) => 0,
Err(error) => {
eprintln!("the verification window failed: {error}");
1
}
})
}
/// Present `url` in the verification window and wait for the token it yields.
///
/// `None` when the viewer closed the window, or it could not be shown.
/// Blocking: call it off the UI thread and outside the async runtime.
pub fn solve(url: &str) -> Option<String> {
let exe = std::env::current_exe()
.inspect_err(|error| tracing::warn!("locate this executable: {error}"))
.ok()?;
let mut child = std::process::Command::new(exe)
.arg(ARG)
.arg(url)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.spawn()
.inspect_err(|error| tracing::warn!("open the verification window: {error}"))
.ok()?;
let mut out = String::new();
if let Some(mut stdout) = child.stdout.take() {
let _ = stdout.read_to_string(&mut out);
}
let status = child.wait().ok()?;
if !status.success() {
tracing::warn!("the verification window exited with {status}");
return None;
}
out.lines()
.map(str::trim)
.rfind(|line| !line.is_empty())
.map(str::to_owned)
}
fn window(url: &str) -> anyhow::Result<()> {
use tao::event::{Event, WindowEvent};
use tao::event_loop::{ControlFlow, EventLoopBuilder};
use tao::window::WindowBuilder;
use wry::{NewWindowResponse, WebViewBuilder};
let event_loop = EventLoopBuilder::<String>::with_user_event().build();
let window = WindowBuilder::new()
.with_title("Confirm you are human — Proton Stream")
.with_inner_size(tao::dpi::LogicalSize::new(440.0, 680.0))
.build(&event_loop)?;
let proxy = event_loop.create_proxy();
let builder = WebViewBuilder::new()
.with_url(url)
.with_initialization_script(BRIDGE)
.with_ipc_handler(move |request| {
// Every message the page posts lands here, most of them its own
// chatter; only a completion carries a token.
if let Some(token) = pstr_core::account::verification_token(request.body()) {
let _ = proxy.send_event(token);
}
})
// The bridge above is reachable by whatever the page shows, so the
// page stays on Proton.
.with_navigation_handler(|url| is_allowed_navigation(&url))
.with_new_window_req_handler(|_, _| NewWindowResponse::Deny);
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
let _webview = {
use tao::platform::unix::WindowExtUnix as _;
use wry::WebViewBuilderExtUnix as _;
let vbox = window
.default_vbox()
.ok_or_else(|| anyhow::anyhow!("the window has no GTK container"))?;
builder.build_gtk(vbox)?
};
#[cfg(any(target_os = "windows", target_os = "macos"))]
let _webview = builder.build(&window)?;
let mut answered = false;
event_loop.run(move |event, _, control_flow| {
*control_flow = ControlFlow::Wait;
match event {
Event::UserEvent(token) if !answered => {
answered = true;
let mut stdout = std::io::stdout().lock();
let _ = writeln!(stdout, "{token}");
let _ = stdout.flush();
*control_flow = ControlFlow::Exit;
}
Event::WindowEvent {
event: WindowEvent::CloseRequested,
..
} => *control_flow = ControlFlow::Exit,
_ => {}
}
})
}
/// Whether `url` is a page of Proton's own, over HTTPS.
fn is_proton_page(url: &str) -> bool {
let Some(rest) = url.strip_prefix("https://") else {
return false;
};
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
// A user-info part would let `proton.me@evil.example` pass.
if authority.contains('@') {
return false;
}
let host = authority.split(':').next().unwrap_or_default();
host == "proton.me" || host.ends_with(".proton.me")
}
/// Whether the webview may go to `url`: Proton's pages, and the documents a
/// page makes for itself, which have no origin of their own to leave to.
fn is_allowed_navigation(url: &str) -> bool {
is_proton_page(url) || url == "about:blank" || url.starts_with("about:srcdoc")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_proton_pages_over_https_are_proton_pages() {
assert!(is_proton_page(
"https://verify.proton.me/?embed=1&methods=captcha&token=x"
));
assert!(is_proton_page("https://proton.me"));
assert!(!is_proton_page("http://verify.proton.me/"));
assert!(!is_proton_page("https://proton.me.evil.example/"));
assert!(!is_proton_page("https://evilproton.me/"));
assert!(!is_proton_page("https://proton.me@evil.example/"));
assert!(!is_proton_page(
"https://evil.example/?next=https://proton.me"
));
}
#[test]
fn the_window_stays_on_proton() {
assert!(is_allowed_navigation(
"https://verify-api.proton.me/core/v4/captcha"
));
assert!(is_allowed_navigation("about:blank"));
assert!(!is_allowed_navigation("https://example.com/"));
assert!(!is_allowed_navigation("file:///etc/passwd"));
}
}
+1
View File
@@ -23,5 +23,6 @@ tokio.workspace = true
anyhow.workspace = true anyhow.workspace = true
clap.workspace = true clap.workspace = true
rpassword.workspace = true rpassword.workspace = true
parking_lot.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
+189 -5
View File
@@ -8,9 +8,12 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use proton_sdk::ids::{LinkId, NodeUid, VolumeId}; use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use pstr_core::account::{PlaceKind, SignIn};
use pstr_core::catalog::{Catalog, build_rows}; use pstr_core::catalog::{Catalog, build_rows};
use pstr_core::config::AppDirs; use pstr_core::config::AppDirs;
use pstr_core::{ShareStore, SharedLibrary}; use pstr_core::shares::AccountFolder;
use pstr_core::sync::WatchSync;
use pstr_core::{Account, AccountStore, KeyringSecretStore, ShareStore, SharedLibrary};
use pstr_player::{EndReason, Player, PlayerConfig, PlayerEvent}; use pstr_player::{EndReason, Player, PlayerConfig, PlayerEvent};
use pstr_stream::{DiskCacheConfig, LibraryOpener, StreamConfig, StreamSource}; use pstr_stream::{DiskCacheConfig, LibraryOpener, StreamConfig, StreamSource};
@@ -69,8 +72,38 @@ enum Command {
#[arg(long)] #[arg(long)]
custom_password: bool, custom_password: bool,
}, },
/// Add a folder of the signed-in account as a share.
///
/// The folder is named by the `VOLUME:LINK` id `browse` prints.
AddFolder {
/// What to call it in the UI.
#[arg(long)]
name: String,
/// The folder, as `VOLUME:LINK`.
folder: String,
},
/// List configured shares. /// List configured shares.
Shares, Shares,
/// Sign in to a Proton account, for watch history across devices and for
/// folders of your own Drive.
///
/// The password, a second-factor code and a mailbox password are prompted
/// for as the account needs them.
Login {
/// The account's address.
username: String,
},
/// Sign out and forget the stored session.
Logout,
/// Show which account is signed in.
Account,
/// List the account's Drive: its top-level places, or one folder.
Browse {
/// The folder to list, as `VOLUME:LINK`; the places when omitted.
folder: Option<String>,
},
/// Sync watch history with the signed-in account's Drive.
Sync,
/// Forget a share and its stored credentials. /// Forget a share and its stored credentials.
Remove { Remove {
/// The share id, as printed by `shares`. /// The share id, as printed by `shares`.
@@ -210,6 +243,7 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
let dirs = AppDirs::ensure().context("resolve app directories")?; let dirs = AppDirs::ensure().context("resolve app directories")?;
let store = ShareStore::new(dirs.clone()); let store = ShareStore::new(dirs.clone());
let accounts = AccountStore::new(std::sync::Arc::new(KeyringSecretStore));
match cli.command { match cli.command {
Command::Add { Command::Add {
@@ -228,6 +262,80 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
println!("added {} ({})", share.name, share.id); println!("added {} ({})", share.name, share.id);
} }
Command::AddFolder { name, folder } => {
let uid = parse_uid(&folder)?;
let share = store
.add_folder(
&name,
AccountFolder {
volume_id: uid.volume_id.as_str().to_owned(),
link_id: uid.link_id.as_str().to_owned(),
},
)
.context("add folder")?;
println!("added {} ({})", share.name, share.id);
}
Command::Login { username } => login(&accounts, &username).await?,
Command::Logout => {
let account = accounts.resume().ok().flatten();
accounts
.sign_out(account.as_ref())
.await
.context("sign out")?;
println!("signed out");
}
Command::Account => match accounts.username().context("read the account")? {
Some(username) => println!("signed in as {username}"),
None => println!("not signed in — sign in with `pstr login <address>`"),
},
Command::Browse { folder } => {
let account = signed_in(&accounts)?;
match folder {
None => {
for place in account.places().await.context("list places")? {
let kind = match place.kind {
PlaceKind::MyFiles => "files",
PlaceKind::Device => "device",
PlaceKind::SharedWithMe => "shared",
};
println!("{}\t{kind}\t{}", format_uid(&place.uid), place.name);
}
}
Some(folder) => {
let entries = account
.folder_children(&parse_uid(&folder)?)
.await
.context("list folder")?;
for entry in entries {
let kind = if entry.is_folder { "dir" } else { "file" };
println!("{}\t{kind}\t{}", format_uid(&entry.uid), entry.name);
}
}
}
}
Command::Sync => {
let account = signed_in(&accounts)?;
let sync = WatchSync::open(&dirs).context("open sync state")?;
let catalog =
parking_lot::Mutex::new(Catalog::open(&dirs.catalog_db()).context("open catalog")?);
let report = sync.sync(&account, &catalog).await.context("sync")?;
println!(
"read {} other device(s), took {} newer position(s), {}",
report.devices,
report.applied,
if report.uploaded {
"uploaded this device's history"
} else {
"this device's history was already up to date"
}
);
}
Command::Shares => { Command::Shares => {
let shares = store.list().context("list shares")?; let shares = store.list().context("list shares")?;
if shares.is_empty() { if shares.is_empty() {
@@ -239,7 +347,12 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
} else { } else {
"" ""
}; };
println!("{}\t{}{}", share.id, share.name, custom); let kind = if share.folder.is_some() {
" [account folder]"
} else {
""
};
println!("{}\t{}{}{}", share.id, share.name, custom, kind);
} }
} }
@@ -249,7 +362,8 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
} }
Command::Crawl { share } => { Command::Crawl { share } => {
let (library, failures) = SharedLibrary::open_all(&store) let account = accounts.resume().context("resume the account")?;
let (library, failures) = SharedLibrary::open_all(&store, account.as_ref())
.await .await
.context("open shares")?; .context("open shares")?;
for (share, error) in &failures { for (share, error) in &failures {
@@ -643,7 +757,10 @@ async fn open_target(
println!("file: {} ({})", target.name, target.share_id); println!("file: {} ({})", target.name, target.share_id);
let (library, failures) = SharedLibrary::open_all(store) let account = AccountStore::new(std::sync::Arc::new(KeyringSecretStore))
.resume()
.context("resume the account")?;
let (library, failures) = SharedLibrary::open_all(store, account.as_ref())
.await .await
.context("open shares")?; .context("open shares")?;
for (share, error) in &failures { for (share, error) in &failures {
@@ -967,7 +1084,74 @@ async fn play(
Ok(()) Ok(())
} }
/// Unix seconds, for the "when was this asked" columns. /// Walk an interactive sign-in through whatever the account asks for.
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. The desktop app has
// one, and keeps its session in the same keyring entry.
anyhow::bail!(
"Proton wants a CAPTCHA before it accepts this sign-in. Sign in once in the \
desktop app on this computer, which shows it and shares its session with \
pstr, 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) => {
// Security keys need WebAuthn, which this sign-in does not speak.
let code = read_password("Code from your authenticator app: ")?;
pending
.second_factor(&code)
.await
.context("second factor")?
}
SignIn::MailboxPassword(pending) => {
let mailbox = read_password("Mailbox password: ")?;
SignIn::Done(
pending
.mailbox_password(&mailbox)
.await
.context("mailbox password")?,
)
}
};
}
}
fn signed_in(accounts: &AccountStore) -> Result<Account> {
accounts
.resume()
.context("resume the account")?
.ok_or_else(|| anyhow::anyhow!("not signed in — sign in with `pstr login <address>`"))
}
/// A node as `browse` prints it and `add-folder` takes it.
fn format_uid(uid: &NodeUid) -> String {
format!("{}:{}", uid.volume_id.as_str(), uid.link_id.as_str())
}
fn parse_uid(text: &str) -> Result<NodeUid> {
let (volume, link) = text
.split_once(':')
.filter(|(volume, link)| !volume.is_empty() && !link.is_empty())
.ok_or_else(|| anyhow::anyhow!("expected VOLUME:LINK, as `pstr browse` prints"))?;
Ok(NodeUid::new(
VolumeId::new(volume.to_owned()),
LinkId::new(link.to_owned()),
))
}
fn unix_now() -> i64 { fn unix_now() -> i64 {
std::time::SystemTime::now() std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
+2
View File
@@ -20,6 +20,8 @@ tracing.workspace = true
directories.workspace = true directories.workspace = true
rusqlite.workspace = true rusqlite.workspace = true
sha2.workspace = true sha2.workspace = true
parking_lot.workspace = true
getrandom.workspace = true
# The credential store, per platform. Without the target's feature `keyring` # The credential store, per platform. Without the target's feature `keyring`
# compiles fine and silently resolves to its in-memory `mock` store, which # compiles fine and silently resolves to its in-memory `mock` store, which
+673
View File
@@ -0,0 +1,673 @@
//! 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
//!
//! One JSON blob in the [`SecretStore`] holding the session tokens and, per
//! account key, the passphrase that unlocks it. Those passphrases are derived
//! at sign-in from the mailbox password and the key salts, and kept *instead*
//! of the password: they unlock the key chain just the same, but a store that
//! leaks gives away this app's access to the account rather than the password
//! — which, on a single-password account, is the login password itself.
//! Deriving them later is not an option either way: `core/v4/keys/salts`
//! needs a scope only a password sign-in grants, and answers 403 after the
//! first token refresh.
//!
//! 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::collections::HashMap;
use std::sync::Arc;
use proton_drive_rs::{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";
/// Why an account whose only second factor is a security key cannot sign in.
const SECURITY_KEY_ONLY: &str = "This account's only second factor is a security key, which this \
app cannot use yet. Add an authenticator app in your Proton account settings, then sign in \
with its code.";
/// Blocks in flight for streaming from the account, as for a public link —
/// see `ShareStore::open`.
const MAX_INFLIGHT_BLOCKS: usize = 48;
/// Everything needed to resume the session unattended.
#[derive(Serialize, Deserialize, Clone)]
struct StoredSession {
session_id: String,
username: String,
user_id: String,
access_token: String,
refresh_token: String,
scopes: Vec<String>,
/// `1` single password, `2` separate mailbox password — Proton's values.
password_mode: u8,
/// Key id → the passphrase that unlocks it. ASCII: they are bcrypt output.
key_passphrases: HashMap<String, String>,
}
impl StoredSession {
fn capture(
session: &ProtonApiSession,
tokens: proton_sdk::http::Tokens,
key_passphrases: HashMap<String, String>,
) -> 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,
},
key_passphrases,
}
}
fn unlock(&self) -> HashMap<String, Vec<u8>> {
self.key_passphrases
.iter()
.map(|(id, passphrase)| (id.clone(), passphrase.as_bytes().to_vec()))
.collect()
}
fn resume_parameters(&self) -> ResumeParameters {
ResumeParameters {
session_id: self.session_id.clone().into(),
username: self.username.clone(),
user_id: self.user_id.clone().into(),
access_token: self.access_token.clone(),
refresh_token: self.refresh_token.clone(),
scopes: self.scopes.clone(),
is_waiting_for_second_factor_code: false,
password_mode: match self.password_mode {
1 => PasswordMode::Single,
_ => PasswordMode::Dual,
},
}
}
}
/// A signed-in account, ready for Drive work.
///
/// Cheap to clone; clones share one session and one set of caches.
#[derive(Clone)]
pub struct Account {
session: ProtonApiSession,
drive: ProtonDriveClient,
}
impl Account {
/// The address the viewer signed in with.
pub fn username(&self) -> &str {
self.session.username()
}
/// The Drive client the account's folders and watch history go through.
pub fn drive(&self) -> &ProtonDriveClient {
&self.drive
}
/// Where a library folder can come from: My Files, each device, and each
/// folder someone shared with the viewer.
///
/// The device this app keeps watch history in is left out — it holds
/// nothing to watch. A device or share whose name will not decrypt is
/// still listed, under a placeholder, rather than hidden: it is the
/// viewer's, and they can still open it.
pub async fn places(&self) -> Result<Vec<DrivePlace>> {
let mut places = Vec::new();
let my_files = self.drive.get_my_files_folder().await?;
places.push(DrivePlace {
kind: PlaceKind::MyFiles,
name: "My files".to_owned(),
uid: my_files.uid,
});
let mut devices = self.drive.enumerate_devices().await?;
devices.sort_by_key(|device| device.creation_time);
for device in devices {
let name = device.name.unwrap_or_else(|_| "Unnamed device".to_owned());
if name == crate::sync::DEVICE_NAME {
continue;
}
places.push(DrivePlace {
kind: PlaceKind::Device,
name,
uid: device.root_folder_uid,
});
}
let shared: Vec<NodeUid> = self
.drive
.enumerate_shared_with_me()
.await?
.into_iter()
.map(|item| item.uid)
.collect();
if !shared.is_empty() {
let mut nodes = self.drive.enumerate_nodes(&shared).await?;
nodes.retain(|node| node.is_folder() && !node.trashed);
nodes.sort_by_key(|node| node.name.to_lowercase());
places.extend(nodes.into_iter().map(|node| DrivePlace {
kind: PlaceKind::SharedWithMe,
name: node.name,
uid: node.uid,
}));
}
Ok(places)
}
/// What one folder of the account holds: folders first, then files, each
/// by name. Trashed nodes are left out.
pub async fn folder_children(&self, folder: &NodeUid) -> Result<Vec<DriveEntry>> {
let uids = self
.drive
.enumerate_folder_children_node_uids(folder)
.await?;
if uids.is_empty() {
return Ok(Vec::new());
}
let mut entries: Vec<DriveEntry> = self
.drive
.enumerate_nodes(&uids)
.await?
.into_iter()
.filter(|node| !node.trashed)
.map(|node| {
let (size, media_type) = match &node.kind {
NodeKind::File {
media_type,
claimed_size,
..
} => (*claimed_size, Some(media_type.clone())),
_ => (None, None),
};
DriveEntry {
is_folder: node.is_folder(),
uid: node.uid,
name: node.name,
size,
media_type,
}
})
.collect();
entries.sort_by(|a, b| {
b.is_folder
.cmp(&a.is_folder)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
Ok(entries)
}
/// End the session on Proton's side. Best-effort: a session that is
/// already dead has nothing to end.
async fn end(&self) {
if let Err(error) = self.session.end().await {
tracing::debug!(%error, "ending the Proton session failed");
}
}
}
/// Which kind of place a [`DrivePlace`] is.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlaceKind {
MyFiles,
Device,
SharedWithMe,
}
/// A top-level place in the account's Drive.
#[derive(Debug, Clone)]
pub struct DrivePlace {
pub kind: PlaceKind,
pub name: String,
pub uid: NodeUid,
}
/// One node of a folder listing.
#[derive(Debug, Clone)]
pub struct DriveEntry {
pub uid: NodeUid,
pub name: String,
pub is_folder: bool,
/// Plaintext size, when the uploader recorded one.
pub size: Option<i64>,
pub media_type: Option<String>,
}
/// Where a sign-in stands.
pub enum SignIn {
/// Signed in; the session is stored.
Done(Account),
/// The account has two-factor authentication: ask for a code and pass it to
/// [`PendingSignIn::second_factor`].
SecondFactor(PendingSignIn),
/// The account has a separate mailbox password: ask for it and pass it to
/// [`PendingSignIn::mailbox_password`].
MailboxPassword(PendingSignIn),
}
/// A sign-in waiting on the viewer.
pub struct PendingSignIn {
secrets: Arc<dyn SecretStore>,
session: ProtonApiSession,
password: String,
}
impl PendingSignIn {
/// Submit a second-factor code.
///
/// A wrong code fails here and leaves nothing behind; the front end starts
/// the sign-in over, because Proton does not accept a second try on the
/// same half-made session.
pub async fn second_factor(mut self, code: &str) -> Result<SignIn> {
self.session
.apply_second_factor_code(code.trim())
.await
.map_err(|error| match error {
ProtonError::Api(api) if (400..500).contains(&api.http_status) => {
Error::SignIn("that code was not accepted".to_owned())
}
other => other.into(),
})?;
self.after_second_factor().await
}
/// Submit the mailbox password, on an account that has one.
///
/// By reference, unlike the code: a mistyped mailbox password is checked
/// on this side, the session survives it, and the viewer can try again
/// without starting over.
pub async fn mailbox_password(&self, mailbox_password: &str) -> Result<Account> {
finish(
Arc::clone(&self.secrets),
self.session.clone(),
mailbox_password,
)
.await
}
async fn after_second_factor(self) -> Result<SignIn> {
if self.session.password_mode() == PasswordMode::Dual {
return Ok(SignIn::MailboxPassword(self));
}
let password = self.password.clone();
finish(self.secrets, self.session, &password)
.await
.map(SignIn::Done)
}
}
/// The signed-in account, if any, and signing in and out.
#[derive(Clone)]
pub struct AccountStore {
secrets: Arc<dyn SecretStore>,
}
impl AccountStore {
pub fn new(secrets: Arc<dyn SecretStore>) -> Self {
Self { secrets }
}
/// The address of the stored account, without touching the network.
pub fn username(&self) -> Result<Option<String>> {
Ok(self.load()?.map(|stored| stored.username))
}
/// Start signing in.
///
/// `verification` is the token from a solved CAPTCHA, after a first try
/// failed with [`Error::HumanVerification`]; the sign-in starts over with
/// it, because the gated attempt's SRP handshake is spent.
pub async fn sign_in(
&self,
username: &str,
password: &str,
verification: Option<HumanVerificationCredential>,
) -> Result<SignIn> {
let session = ProtonApiSession::begin_verified(
client_configuration(),
username.trim(),
password.as_bytes(),
verification.as_ref(),
)
.await
.map_err(classify_sign_in_failure)?;
let pending = PendingSignIn {
secrets: Arc::clone(&self.secrets),
session,
password: password.to_owned(),
};
if pending.session.is_waiting_for_second_factor() {
let methods = pending.session.second_factor_methods();
if methods.fido2 && !methods.totp {
// A security key needs WebAuthn, which this sign-in does not
// speak; asking for a code would only fail as a wrong one.
pending.session.end().await.ok();
return Err(Error::SignIn(SECURITY_KEY_ONLY.to_owned()));
}
return Ok(SignIn::SecondFactor(pending));
}
pending.after_second_factor().await
}
/// The stored account, resumed, or `None` when nobody is signed in.
///
/// Makes no request: a session whose refresh token has died is found out
/// by the first call that uses it, as [`Error::is_signed_out`].
pub fn resume(&self) -> Result<Option<Account>> {
let Some(stored) = self.load()? else {
return Ok(None);
};
let session = ProtonApiSession::resume(client_configuration(), stored.resume_parameters())?;
persist_refreshed_tokens(&self.secrets, &session, stored.key_passphrases.clone());
let drive = ProtonDriveClient::with_key_passphrases(&session, stored.unlock());
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)
}
/// The stored session, or `None` for none — or for one this build cannot
/// read, such as the shape development builds before 1.2.0 stored, which
/// kept the mailbox password. Signing in again is the only fix for either,
/// and it replaces what is there.
fn load(&self) -> Result<Option<StoredSession>> {
let Some(encoded) = self.secrets.get(SESSION_KEY)? else {
return Ok(None);
};
Ok(serde_json::from_str(&encoded)
.inspect_err(|error| {
tracing::warn!(%error, "the stored Proton session is unreadable; sign in again");
})
.ok())
}
}
/// Unlock the key chain with `mailbox_password`, store the session, and hand
/// back the account.
///
/// The key passphrases are derived here, while the token still has the scope
/// the key salts need. Loading the addresses is what proves the mailbox
/// password: it unlocks the user keys, and a wrong password unlocks none of
/// them. Only the passphrases are stored; the password goes no further.
async fn finish(
secrets: Arc<dyn SecretStore>,
session: ProtonApiSession,
mailbox_password: &str,
) -> Result<Account> {
let probe = ProtonDriveClient::new(&session, mailbox_password.as_bytes().to_vec());
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 key_passphrases = probe
.account()
.key_passphrases()
.await?
.into_iter()
.map(|(id, passphrase)| {
String::from_utf8(passphrase)
.map(|passphrase| (id, passphrase))
.map_err(|_| Error::Config("a derived key passphrase is not text".to_owned()))
})
.collect::<Result<HashMap<_, _>>>()?;
let stored = StoredSession::capture(
&session,
session.current_tokens().await,
key_passphrases.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, key_passphrases);
let drive = ProtonDriveClient::with_key_passphrases(&session, stored.unlock());
Ok(account(session, drive))
}
fn account(session: ProtonApiSession, drive: ProtonDriveClient) -> Account {
let drive = drive
.with_max_inflight_blocks(MAX_INFLIGHT_BLOCKS)
// A watch-history file is one small block; the single-request upload
// is one failure point where the draft dance is three.
.with_small_file_upload(true);
Account { session, drive }
}
/// Write every refreshed token pair back to the secret store.
///
/// Refresh tokens are single-use: once the HTTP client has spent the stored
/// one, only the pair it got back can resume the session.
fn persist_refreshed_tokens(
secrets: &Arc<dyn SecretStore>,
session: &ProtonApiSession,
key_passphrases: HashMap<String, String>,
) {
let secrets = Arc::clone(secrets);
let template = session.clone();
session.http().set_on_tokens_refreshed(move |tokens| {
let stored = StoredSession::capture(&template, tokens, key_passphrases.clone());
let written = serde_json::to_string(&stored)
.map_err(|e| Error::Config(e.to_string()))
.and_then(|encoded| secrets.set(SESSION_KEY, &encoded));
if let Err(error) = written {
tracing::warn!(%error, "the refreshed Proton session could not be stored");
}
});
}
/// Lift a CAPTCHA gate and a wrong password out of the generic API error.
///
/// Only a gate that can be solved in an app becomes
/// [`Error::HumanVerification`]; one that wants an e-mail or SMS code is a plain
/// failure, which reads better than an empty verification page.
fn classify_sign_in_failure(error: ProtonError) -> Error {
if let ProtonError::Api(api) = &error {
if let Some(challenge) = api.human_verification()
&& challenge.supports_captcha()
{
return Error::HumanVerification(Box::new(challenge));
}
if api.http_status == 422 && !api.is_human_verification_required() {
return Error::SignIn(if api.message.is_empty() {
"Proton did not accept that address and password".to_owned()
} else {
api.message.clone()
});
}
}
error.into()
}
/// The token out of a message posted by Proton's verification page, or `None`
/// for anything that is not a completion.
///
/// The page posts plenty besides — resize requests, readiness pings — and
/// taking an unrecognised message for success would retry the sign-in with a
/// garbage token. Both spellings of the success type have shipped, and some
/// hosts hand the message over stringified twice.
pub fn verification_token(raw: &str) -> Option<String> {
let mut value: serde_json::Value = serde_json::from_str(raw).ok()?;
if let Some(inner) = value.as_str()
&& let Ok(parsed) = serde_json::from_str(inner)
{
value = parsed;
}
let kind = value.get("type")?.as_str()?;
if !matches!(
kind,
"HUMAN_VERIFICATION_SUCCESS" | "human_verification_success"
) {
return None;
}
let token = value.get("payload")?.get("token")?.as_str()?;
(!token.is_empty()).then(|| token.to_owned())
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use parking_lot::Mutex;
use super::*;
#[derive(Default)]
struct MemorySecrets(Mutex<HashMap<String, String>>);
impl SecretStore for MemorySecrets {
fn set(&self, key: &str, value: &str) -> Result<()> {
self.0.lock().insert(key.to_owned(), value.to_owned());
Ok(())
}
fn get(&self, key: &str) -> Result<Option<String>> {
Ok(self.0.lock().get(key).cloned())
}
fn delete(&self, key: &str) -> Result<()> {
self.0.lock().remove(key);
Ok(())
}
}
fn stored() -> StoredSession {
StoredSession {
session_id: "session".to_owned(),
username: "viewer@proton.me".to_owned(),
user_id: "user".to_owned(),
access_token: "access".to_owned(),
refresh_token: "refresh".to_owned(),
scopes: vec!["drive".to_owned()],
password_mode: 2,
key_passphrases: HashMap::from([("key-1".to_owned(), "derived".to_owned())]),
}
}
#[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 the_stored_session_holds_no_password() {
let encoded = serde_json::to_string(&stored()).unwrap();
assert!(!encoded.contains("password\""), "{encoded}");
assert!(!encoded.contains("key_salts"), "{encoded}");
}
#[test]
fn a_session_stored_by_an_older_build_reads_as_signed_out() {
let secrets = Arc::new(MemorySecrets::default());
secrets
.set(
SESSION_KEY,
r#"{"session_id":"s","username":"u","user_id":"i","access_token":"a",
"refresh_token":"r","scopes":[],"password_mode":1,
"mailbox_password":"hunter2","key_salts":[]}"#,
)
.unwrap();
let store = AccountStore::new(secrets);
assert_eq!(store.username().expect("read"), None);
assert!(store.resume().expect("resume").is_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}");
}
}
}
+142 -9
View File
@@ -513,19 +513,17 @@ impl Catalog {
Ok(rows.collect::<std::result::Result<_, _>>()?) Ok(rows.collect::<std::result::Result<_, _>>()?)
} }
/// Drop everything a share contributed, watch positions included. /// Drop everything a share contributed except where it was watched.
/// ///
/// Unlike a recrawl — which replaces rows and deliberately leaves watch /// Watch state stays, as through a recrawl. It is history, and not only
/// state alone — this is the viewer saying they are done with the share. /// this device's: with an account signed in, the next sync would bring it
/// Keeping positions for files they can no longer reach would only leave /// straight back from the other devices' files, so deleting it here would
/// the "continue watching" row pointing at nothing. /// only make it flicker. Nothing shows it — the library pairs positions
/// with files, and the files are gone — until the share is added again,
/// when it picks up where it left off.
pub fn remove_share(&mut self, share_id: &str) -> Result<()> { pub fn remove_share(&mut self, share_id: &str) -> Result<()> {
let tx = self.conn.transaction()?; let tx = self.conn.transaction()?;
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?; tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
tx.execute(
"DELETE FROM watch_state WHERE share_id = ?1",
params![share_id],
)?;
tx.execute( tx.execute(
"DELETE FROM offline_files WHERE share_id = ?1", "DELETE FROM offline_files WHERE share_id = ?1",
params![share_id], params![share_id],
@@ -721,6 +719,46 @@ impl Catalog {
Ok(()) Ok(())
} }
/// Take each of `states` that is newer than what is recorded, in one
/// transaction, returning how many were taken.
///
/// Newer is a later `updated_at`. A tie is broken by the state itself —
/// watched over not, then the further position — rather than by who is
/// asking, so two devices holding the same tie settle on the same answer
/// instead of each keeping its own forever. See [`crate::sync`].
pub fn merge_watch_states(&mut self, states: &[(String, String, WatchState)]) -> Result<usize> {
let tx = self.conn.transaction()?;
let mut taken = 0;
{
let mut upsert = tx.prepare_cached(
"INSERT INTO watch_state (share_id, link_id, position_secs, duration_secs, watched, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT (share_id, link_id) DO UPDATE SET
position_secs = excluded.position_secs,
duration_secs = excluded.duration_secs,
watched = excluded.watched,
updated_at = excluded.updated_at
WHERE excluded.updated_at > watch_state.updated_at
OR (excluded.updated_at = watch_state.updated_at
AND (excluded.watched > watch_state.watched
OR (excluded.watched = watch_state.watched
AND excluded.position_secs > watch_state.position_secs)))",
)?;
for (share_id, link_id, state) in states {
taken += upsert.execute(params![
share_id,
link_id,
state.position_secs,
state.duration_secs,
state.watched as i64,
state.updated_at,
])?;
}
}
tx.commit()?;
Ok(taken)
}
/// Where playback left off, if it ever started. /// Where playback left off, if it ever started.
pub fn watch_state(&self, share_id: &str, link_id: &str) -> Result<Option<WatchState>> { pub fn watch_state(&self, share_id: &str, link_id: &str) -> Result<Option<WatchState>> {
let state = self let state = self
@@ -2390,6 +2428,101 @@ mod tests {
assert_eq!(rows[0].link_id, "film"); assert_eq!(rows[0].link_id, "film");
} }
#[test]
fn removing_a_share_keeps_its_watch_state_out_of_sight() {
let mut catalog = Catalog::in_memory().expect("open");
catalog
.replace_share("s1", &[file("s1", "a", "Show S01E01.mkv")])
.expect("crawl");
let state = WatchState {
position_secs: 120.0,
duration_secs: Some(1440.0),
watched: false,
updated_at: 10,
};
catalog.set_watch_state("s1", "a", &state).expect("set");
catalog.remove_share("s1").expect("remove");
let library = crate::library::Library::build(
catalog.all_files().expect("files"),
&catalog.all_watch_states().expect("watch"),
);
assert!(library.titles.is_empty());
assert_eq!(catalog.watch_state("s1", "a").expect("read"), Some(state));
catalog
.replace_share("s1", &[file("s1", "a", "Show S01E01.mkv")])
.expect("re-add");
let library = crate::library::Library::build(
catalog.all_files().expect("files"),
&catalog.all_watch_states().expect("watch"),
);
assert_eq!(library.titles[0].seasons[0].episodes[0].watch, Some(state));
}
#[test]
fn merging_takes_only_states_newer_than_the_recorded_ones() {
let mut catalog = Catalog::in_memory().expect("open");
let at = |position_secs: f64, updated_at: i64| WatchState {
position_secs,
duration_secs: Some(1440.0),
watched: false,
updated_at,
};
catalog
.set_watch_state("s1", "kept", &at(100.0, 50))
.unwrap();
catalog
.set_watch_state("s1", "taken", &at(100.0, 50))
.unwrap();
let taken = catalog
.merge_watch_states(&[
("s1".to_owned(), "kept".to_owned(), at(900.0, 40)),
("s1".to_owned(), "taken".to_owned(), at(900.0, 60)),
("s1".to_owned(), "new".to_owned(), at(5.0, 10)),
])
.expect("merge");
assert_eq!(taken, 2);
assert_eq!(
catalog.watch_state("s1", "kept").unwrap(),
Some(at(100.0, 50))
);
assert_eq!(
catalog.watch_state("s1", "taken").unwrap(),
Some(at(900.0, 60))
);
assert_eq!(catalog.watch_state("s1", "new").unwrap(), Some(at(5.0, 10)));
}
#[test]
fn a_merge_tie_settles_the_same_way_whichever_side_holds_it() {
let state = |position_secs: f64, watched: bool| WatchState {
position_secs,
duration_secs: Some(1440.0),
watched,
updated_at: 70,
};
for (local, remote) in [
(state(300.0, false), state(1440.0, true)),
(state(1440.0, true), state(300.0, false)),
(state(300.0, false), state(600.0, false)),
(state(600.0, false), state(300.0, false)),
] {
let mut catalog = Catalog::in_memory().expect("open");
catalog.set_watch_state("s1", "a", &local).unwrap();
catalog
.merge_watch_states(&[("s1".to_owned(), "a".to_owned(), remote)])
.unwrap();
let winner =
if (local.watched, local.position_secs) > (remote.watched, remote.position_secs) {
local
} else {
remote
};
assert_eq!(catalog.watch_state("s1", "a").unwrap(), Some(winner));
}
}
#[test] #[test]
fn an_unwatched_file_has_no_watch_state() { fn an_unwatched_file_has_no_watch_state() {
let catalog = Catalog::in_memory().expect("open"); let catalog = Catalog::in_memory().expect("open");
+6
View File
@@ -88,6 +88,12 @@ impl AppDirs {
self.config.join("shares.json") self.config.join("shares.json")
} }
/// This installation's identity for watch-history sync. See
/// [`crate::sync`].
pub fn sync_file(&self) -> PathBuf {
self.config.join("sync.json")
}
/// The catalog database. /// The catalog database.
pub fn catalog_db(&self) -> PathBuf { pub fn catalog_db(&self) -> PathBuf {
self.data.join("catalog.db") self.data.join("catalog.db")
+31
View File
@@ -30,6 +30,37 @@ pub enum Error {
/// The caller asked for something that does not exist or does not apply. /// The caller asked for something that does not exist or does not apply.
#[error("{0}")] #[error("{0}")]
NotFound(String), NotFound(String),
/// Proton gated a sign-in behind a CAPTCHA. The front end presents
/// [`HumanVerification::verification_url`](proton_sdk::api::HumanVerification::verification_url)
/// and signs in again with the token it yields.
#[error("Proton wants to confirm you are human before signing in")]
HumanVerification(Box<proton_sdk::api::HumanVerification>),
/// No account is signed in, or its session can no longer be refreshed and
/// the viewer has to sign in again.
#[error("{0}")]
SignedOut(String),
/// Signing in failed for a reason the viewer can act on, such as a wrong
/// mailbox password.
#[error("{0}")]
SignIn(String),
}
impl Error {
/// Whether this is the account's session having ended: the refresh token
/// was revoked or expired, or the token lacks a scope only a password
/// sign-in grants. Nothing but signing in again fixes it.
pub fn is_signed_out(&self) -> bool {
match self {
Self::SignedOut(_) => true,
Self::Drive(proton_sdk::error::ProtonError::Api(api)) => {
api.is_invalid_refresh_token() || api.is_insufficient_scope()
}
_ => false,
}
}
} }
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
+4 -1
View File
@@ -5,6 +5,7 @@
//! how a share is opened and how its contents are modelled. //! how a share is opened and how its contents are modelled.
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
pub mod account;
pub mod appearance; pub mod appearance;
pub mod browse; pub mod browse;
pub mod catalog; pub mod catalog;
@@ -18,11 +19,13 @@ pub mod naming;
pub mod prefs; pub mod prefs;
pub mod shares; pub mod shares;
pub mod shelves; pub mod shelves;
pub mod sync;
pub use account::{Account, AccountStore};
pub use error::{Error, Result}; pub use error::{Error, Result};
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
pub use shares::KeyringSecretStore; pub use shares::KeyringSecretStore;
pub use shares::{SecretStore, Share, ShareStore, SharedLibrary}; pub use shares::{SecretStore, Share, ShareClient, ShareStore, SharedLibrary};
pub use proton_drive_rs; pub use proton_drive_rs;
pub use proton_sdk; pub use proton_sdk;
+421 -20
View File
@@ -14,15 +14,28 @@
//! A library is not one folder. [`SharedLibrary`] opens every configured share //! A library is not one folder. [`SharedLibrary`] opens every configured share
//! and merges them into one catalog, so the app has a single browsable view //! and merges them into one catalog, so the app has a single browsable view
//! regardless of how many links it was given. //! regardless of how many links it was given.
//!
//! ## Folders from a signed-in account
//!
//! A share can also be a folder of the viewer's own Drive — My Files, another
//! device, or something shared with them — opened through the account signed
//! in by [`crate::account`] instead of a link. It has no secrets of its own:
//! the account session is the credential, and without it such a share simply
//! fails to open, the same as a revoked link. [`ShareClient`] puts both kinds
//! behind one surface so the crawl, the stream layer and the thumbnail path
//! do not care which one they hold.
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::sync::Arc; use std::sync::Arc;
use proton_drive_rs::{Node, ProtonDrivePublicLinkClient}; use proton_drive_rs::{
Node, ProtonDriveClient, ProtonDrivePublicLinkClient, RevisionReader, ThumbnailType,
};
use proton_sdk::config::ProtonClientConfiguration; use proton_sdk::config::ProtonClientConfiguration;
use proton_sdk::ids::NodeUid; use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::account::Account;
use crate::config::{AppDirs, read_json, write_json}; use crate::config::{AppDirs, read_json, write_json};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
@@ -42,19 +55,26 @@ pub trait SecretStore: Send + Sync {
} }
/// The desktop OS credential store. /// The desktop OS credential store.
///
/// A value too long for one entry is split across several; see
/// [`ENTRY_UNITS`].
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct KeyringSecretStore; pub struct KeyringSecretStore;
/// One credential-store entry per key, with no splitting.
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
impl KeyringSecretStore { struct KeyringEntries;
#[cfg(not(target_os = "android"))]
impl KeyringEntries {
fn entry(key: &str) -> Result<keyring::Entry> { fn entry(key: &str) -> Result<keyring::Entry> {
Ok(keyring::Entry::new(KEYRING_SERVICE, key)?) Ok(keyring::Entry::new(KEYRING_SERVICE, key)?)
} }
} }
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
impl SecretStore for KeyringSecretStore { impl SecretStore for KeyringEntries {
fn set(&self, key: &str, value: &str) -> Result<()> { fn set(&self, key: &str, value: &str) -> Result<()> {
Self::entry(key)?.set_password(value)?; Self::entry(key)?.set_password(value)?;
Ok(()) Ok(())
@@ -76,6 +96,205 @@ impl SecretStore for KeyringSecretStore {
} }
} }
#[cfg(not(target_os = "android"))]
impl SecretStore for KeyringSecretStore {
fn set(&self, key: &str, value: &str) -> Result<()> {
chunked::set(&KeyringEntries, key, value)
}
fn get(&self, key: &str) -> Result<Option<String>> {
chunked::get(&KeyringEntries, key)
}
fn delete(&self, key: &str) -> Result<()> {
chunked::delete(&KeyringEntries, key)
}
}
/// The most UTF-16 code units one credential-store entry is given.
///
/// Windows caps a credential's blob at 2560 bytes, and `keyring` stores it as
/// UTF-16: 1280 units. A share secret is far below that, but the account
/// session — tokens, scopes, the mailbox password and a salt per key — can
/// pass it, and failing to store a refreshed token signs the viewer out at
/// the next launch. The margin is for the header.
#[cfg(any(not(target_os = "android"), test))]
const ENTRY_UNITS: usize = 1200;
/// Splitting a long secret across several entries of an underlying store.
///
/// A short value is one entry, exactly as before splitting existed, so every
/// secret already stored still reads. A long one is written as `key#0`,
/// `key#1`, … with `key` itself holding [`HEADER`] and the count. The parts go
/// first and the header last, so a reader never finds a header naming parts
/// that are not there yet.
#[cfg(any(not(target_os = "android"), test))]
mod chunked {
use super::{ENTRY_UNITS, SecretStore};
use crate::error::Result;
/// Marks a split value. A short value that happens to start with it is
/// split too, so it cannot be mistaken for a header.
const HEADER: &str = "pstr-chunked:";
fn part(key: &str, index: usize) -> String {
format!("{key}#{index}")
}
fn parts_of(header: &str) -> Option<usize> {
header.strip_prefix(HEADER)?.parse().ok()
}
/// `value` cut into pieces of at most [`ENTRY_UNITS`] UTF-16 units, never
/// inside a character.
fn split(value: &str) -> Vec<&str> {
let mut pieces = Vec::new();
let mut start = 0;
let mut units = 0;
for (at, ch) in value.char_indices() {
if units + ch.len_utf16() > ENTRY_UNITS {
pieces.push(&value[start..at]);
start = at;
units = 0;
}
units += ch.len_utf16();
}
pieces.push(&value[start..]);
pieces
}
pub(super) fn set(store: &dyn SecretStore, key: &str, value: &str) -> Result<()> {
let before = store.get(key)?.as_deref().and_then(parts_of).unwrap_or(0);
let fits = value.encode_utf16().count() <= ENTRY_UNITS && !value.starts_with(HEADER);
let written = if fits {
store.set(key, value)?;
0
} else {
let pieces = split(value);
for (index, piece) in pieces.iter().enumerate() {
store.set(&part(key, index), piece)?;
}
store.set(key, &format!("{HEADER}{}", pieces.len()))?;
pieces.len()
};
for index in written..before {
store.delete(&part(key, index))?;
}
Ok(())
}
pub(super) fn get(store: &dyn SecretStore, key: &str) -> Result<Option<String>> {
let Some(value) = store.get(key)? else {
return Ok(None);
};
let Some(count) = parts_of(&value) else {
return Ok(Some(value));
};
let mut joined = String::new();
for index in 0..count {
// A part gone missing is a value that no longer exists whole;
// half of a token is worse than none.
let Some(piece) = store.get(&part(key, index))? else {
return Ok(None);
};
joined.push_str(&piece);
}
Ok(Some(joined))
}
pub(super) fn delete(store: &dyn SecretStore, key: &str) -> Result<()> {
let count = store.get(key)?.as_deref().and_then(parts_of).unwrap_or(0);
store.delete(key)?;
for index in 0..count {
store.delete(&part(key, index))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use parking_lot::Mutex;
use super::*;
/// An underlying store that refuses what Windows would.
#[derive(Default)]
struct Limited(Mutex<BTreeMap<String, String>>);
impl SecretStore for Limited {
fn set(&self, key: &str, value: &str) -> Result<()> {
assert!(
value.encode_utf16().count() <= 1280,
"{key} is too long for one entry"
);
self.0.lock().insert(key.to_owned(), value.to_owned());
Ok(())
}
fn get(&self, key: &str) -> Result<Option<String>> {
Ok(self.0.lock().get(key).cloned())
}
fn delete(&self, key: &str) -> Result<()> {
self.0.lock().remove(key);
Ok(())
}
}
#[test]
fn a_short_secret_is_one_entry_as_it_always_was() {
let store = Limited::default();
set(&store, "share-a", "fragment").unwrap();
assert_eq!(store.0.lock().len(), 1);
assert_eq!(get(&store, "share-a").unwrap().as_deref(), Some("fragment"));
}
#[test]
fn a_secret_longer_than_an_entry_round_trips() {
let store = Limited::default();
let long: String = "aé😀".repeat(1500);
set(&store, "proton-account", &long).unwrap();
assert!(store.0.lock().len() > 2);
assert_eq!(get(&store, "proton-account").unwrap(), Some(long));
}
#[test]
fn a_shorter_value_leaves_no_parts_of_the_longer_one_behind() {
let store = Limited::default();
set(&store, "k", &"x".repeat(5000)).unwrap();
set(&store, "k", &"y".repeat(1300)).unwrap();
assert_eq!(store.0.lock().len(), 3);
set(&store, "k", "short").unwrap();
assert_eq!(store.0.lock().len(), 1);
assert_eq!(get(&store, "k").unwrap().as_deref(), Some("short"));
}
#[test]
fn a_value_that_looks_like_a_header_is_not_taken_for_one() {
let store = Limited::default();
set(&store, "k", "pstr-chunked:3").unwrap();
assert_eq!(get(&store, "k").unwrap().as_deref(), Some("pstr-chunked:3"));
}
#[test]
fn deleting_a_split_secret_deletes_every_part() {
let store = Limited::default();
set(&store, "k", &"x".repeat(5000)).unwrap();
delete(&store, "k").unwrap();
assert!(store.0.lock().is_empty());
assert_eq!(get(&store, "k").unwrap(), None);
}
#[test]
fn a_split_secret_missing_a_part_reads_as_absent() {
let store = Limited::default();
set(&store, "k", &"x".repeat(5000)).unwrap();
store.delete("k#1").unwrap();
assert_eq!(get(&store, "k").unwrap(), None);
}
}
}
/// What the app records about a share, minus its secrets. /// What the app records about a share, minus its secrets.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Share { pub struct Share {
@@ -92,6 +311,47 @@ pub struct Share {
/// rather than after it fails. /// rather than after it fails.
#[serde(default)] #[serde(default)]
pub has_custom_password: bool, pub has_custom_password: bool,
/// Set when this share is a folder of the signed-in account rather than a
/// public link; `token` is then empty and there are no secrets.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub folder: Option<AccountFolder>,
}
/// A folder of the signed-in account, kept as a library share.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccountFolder {
pub volume_id: String,
pub link_id: String,
}
impl AccountFolder {
pub fn uid(&self) -> NodeUid {
NodeUid::new(
VolumeId::from(self.volume_id.clone()),
LinkId::from(self.link_id.clone()),
)
}
/// The share id this folder is kept under.
///
/// Derived, like a link's `share-{token}`, so the same folder added on two
/// devices is the same share — which is what lets watch history recorded
/// against it on one device land on the other. Hashed rather than spliced
/// in because Proton ids are base64 and may carry characters a catalog key
/// is better off without.
pub fn share_id(&self) -> String {
use sha2::{Digest, Sha256};
let mut hash = Sha256::new();
hash.update(self.volume_id.as_bytes());
hash.update([0]);
hash.update(self.link_id.as_bytes());
let digest = hash.finalize();
let hex: String = digest[..12]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
format!("drive-{hex}")
}
} }
/// The secrets for one share, as held in the credential store. /// The secrets for one share, as held in the credential store.
@@ -154,6 +414,31 @@ impl ShareStore {
name: name.to_string(), name: name.to_string(),
token, token,
has_custom_password: secrets.custom_password.is_some(), has_custom_password: secrets.custom_password.is_some(),
folder: None,
};
shares.push(share.clone());
write_json(&self.dirs.shares_file(), &shares)?;
Ok(share)
}
/// Record a folder of the signed-in account as a share.
///
/// Nothing goes in the credential store: the folder opens through the
/// account's session, which [`crate::account::AccountStore`] keeps.
pub fn add_folder(&self, name: &str, folder: AccountFolder) -> Result<Share> {
let id = folder.share_id();
let mut shares = self.list()?;
if shares.iter().any(|share| share.id == id) {
return Err(Error::Config(
"that folder is already in the library".to_owned(),
));
}
let share = Share {
id,
name: name.to_string(),
token: String::new(),
has_custom_password: false,
folder: Some(folder),
}; };
shares.push(share.clone()); shares.push(share.clone());
write_json(&self.dirs.shares_file(), &shares)?; write_json(&self.dirs.shares_file(), &shares)?;
@@ -184,6 +469,11 @@ impl ShareStore {
.iter_mut() .iter_mut()
.find(|share| share.id == id) .find(|share| share.id == id)
.ok_or_else(|| Error::NotFound(format!("no share with id {id}")))?; .ok_or_else(|| Error::NotFound(format!("no share with id {id}")))?;
if share.folder.is_some() {
return Err(Error::Config(
"that share is a folder of your account and has no link".to_owned(),
));
}
if share.token != token { if share.token != token {
return Err(Error::Config( return Err(Error::Config(
"that link is for a different share; add it as a new one".to_owned(), "that link is for a different share; add it as a new one".to_owned(),
@@ -221,8 +511,20 @@ impl ShareStore {
self.secrets.delete(id) self.secrets.delete(id)
} }
/// Open a share as a visitor. /// Open a share: a link as a visitor, a folder through `account`.
pub async fn open(&self, share: &Share) -> Result<ProtonDrivePublicLinkClient> { pub async fn open(&self, share: &Share, account: Option<&Account>) -> Result<ShareClient> {
if let Some(folder) = &share.folder {
let account = account.ok_or_else(|| {
Error::SignedOut(format!(
"sign in to Proton to open {}, a folder of your account",
share.name
))
})?;
return Ok(ShareClient::Account {
client: account.drive().clone(),
root: folder.uid(),
});
}
let secrets = self.load_secrets(&share.id)?; let secrets = self.load_secrets(&share.id)?;
let client = ProtonDrivePublicLinkClient::open( let client = ProtonDrivePublicLinkClient::open(
client_configuration(), client_configuration(),
@@ -234,7 +536,7 @@ impl ShareStore {
// seeks wants more blocks in flight; at 4 MiB each this is a 192 MiB // seeks wants more blocks in flight; at 4 MiB each this is a 192 MiB
// ceiling, which is unremarkable for a desktop app. // ceiling, which is unremarkable for a desktop app.
.with_max_inflight_blocks(48); .with_max_inflight_blocks(48);
Ok(client) Ok(ShareClient::PublicLink(Box::new(client)))
} }
fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> { fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> {
@@ -256,7 +558,96 @@ impl ShareStore {
/// Several opened shares, presented as one library. /// Several opened shares, presented as one library.
pub struct SharedLibrary { pub struct SharedLibrary {
clients: BTreeMap<String, ProtonDrivePublicLinkClient>, clients: BTreeMap<String, ShareClient>,
}
/// One opened share, whichever kind it is.
///
/// Cheap to clone: both clients share their caches and sessions between
/// clones.
#[derive(Clone)]
pub enum ShareClient {
/// A public link, opened as a visitor. Boxed: the visitor client is
/// several times the size of the account variant, and libraries are
/// mostly links, so every map entry would otherwise pay for it.
PublicLink(Box<ProtonDrivePublicLinkClient>),
/// A folder of the signed-in account.
Account {
client: ProtonDriveClient,
root: NodeUid,
},
}
impl ShareClient {
/// The share's top folder.
pub async fn root_node(&self) -> proton_sdk::error::Result<Node> {
match self {
Self::PublicLink(client) => client.get_root_node().await,
Self::Account { client, root } => client.get_node(root).await?.ok_or_else(|| {
proton_sdk::error::ProtonError::invalid_operation(
"the folder is no longer in your Drive",
)
}),
}
}
pub async fn enumerate_folder_children_node_uids(
&self,
folder: &NodeUid,
) -> proton_sdk::error::Result<Vec<NodeUid>> {
match self {
Self::PublicLink(client) => client.enumerate_folder_children_node_uids(folder).await,
Self::Account { client, .. } => {
client.enumerate_folder_children_node_uids(folder).await
}
}
}
pub async fn enumerate_nodes(&self, uids: &[NodeUid]) -> proton_sdk::error::Result<Vec<Node>> {
match self {
Self::PublicLink(client) => client.enumerate_nodes(uids).await,
Self::Account { client, .. } => client.enumerate_nodes(uids).await,
}
}
pub async fn open_revision(&self, uid: &NodeUid) -> proton_sdk::error::Result<RevisionReader> {
match self {
Self::PublicLink(client) => client.open_revision(uid).await,
Self::Account { client, .. } => client.open_revision(uid).await,
}
}
pub async fn download_range(
&self,
uid: &NodeUid,
offset: u64,
length: u64,
) -> proton_sdk::error::Result<Vec<u8>> {
match self {
Self::PublicLink(client) => client.download_range(uid, offset, length).await,
Self::Account { client, .. } => client.download_range(uid, offset, length).await,
}
}
pub async fn download_thumbnail(
&self,
uid: &NodeUid,
kind: ThumbnailType,
) -> proton_sdk::error::Result<Option<Vec<u8>>> {
match self {
Self::PublicLink(client) => client.download_thumbnail(uid, kind).await,
Self::Account { client, .. } => client.download_thumbnail(uid, kind).await,
}
}
/// Replay a visitor handshake. An account session refreshes its own
/// tokens when one expires, so there is nothing to do for a folder.
pub async fn refresh_session(&self) -> proton_sdk::error::Result<()> {
match self {
Self::PublicLink(client) => client.refresh_session().await,
Self::Account { .. } => Ok(()),
}
}
} }
impl SharedLibrary { impl SharedLibrary {
@@ -265,8 +656,14 @@ impl SharedLibrary {
/// A share that fails to open is reported and skipped rather than failing /// A share that fails to open is reported and skipped rather than failing
/// the whole library — one revoked link should not make the other three /// the whole library — one revoked link should not make the other three
/// unwatchable. /// unwatchable.
pub async fn open_all(store: &ShareStore) -> Result<(Self, Vec<(Share, Error)>)> { ///
Self::open_all_reusing(store, BTreeMap::new()).await /// Folders of the account open through `account`; without one they are
/// reported as failures like any other share that will not open.
pub async fn open_all(
store: &ShareStore,
account: Option<&Account>,
) -> Result<(Self, Vec<(Share, Error)>)> {
Self::open_all_reusing(store, BTreeMap::new(), account).await
} }
/// Open every configured share, reusing the clients in `reusable`. /// Open every configured share, reusing the clients in `reusable`.
@@ -278,7 +675,8 @@ impl SharedLibrary {
/// link. /// link.
pub async fn open_all_reusing( pub async fn open_all_reusing(
store: &ShareStore, store: &ShareStore,
mut reusable: BTreeMap<String, ProtonDrivePublicLinkClient>, mut reusable: BTreeMap<String, ShareClient>,
account: Option<&Account>,
) -> Result<(Self, Vec<(Share, Error)>)> { ) -> Result<(Self, Vec<(Share, Error)>)> {
let mut clients = BTreeMap::new(); let mut clients = BTreeMap::new();
let mut failures = Vec::new(); let mut failures = Vec::new();
@@ -288,7 +686,7 @@ impl SharedLibrary {
clients.insert(share.id, client); clients.insert(share.id, client);
continue; continue;
} }
match store.open(&share).await { match store.open(&share, account).await {
Ok(client) => { Ok(client) => {
clients.insert(share.id.clone(), client); clients.insert(share.id.clone(), client);
} }
@@ -305,19 +703,22 @@ impl SharedLibrary {
/// The excluded share is the one whose credentials or membership just /// The excluded share is the one whose credentials or membership just
/// changed; leaving it out is what makes a removed share's live client /// changed; leaving it out is what makes a removed share's live client
/// unreachable rather than merely stale. /// unreachable rather than merely stale.
pub fn reusable_clients( ///
&self, /// Folders of the account are never handed back: they carry the session
dropping: &str, /// they were opened with, and the account may have signed out or changed
) -> BTreeMap<String, ProtonDrivePublicLinkClient> { /// since.
pub fn reusable_clients(&self, dropping: &str) -> BTreeMap<String, ShareClient> {
self.clients self.clients
.iter() .iter()
.filter(|(id, _)| id.as_str() != dropping) .filter(|(id, client)| {
id.as_str() != dropping && matches!(client, ShareClient::PublicLink(_))
})
.map(|(id, client)| (id.clone(), client.clone())) .map(|(id, client)| (id.clone(), client.clone()))
.collect() .collect()
} }
/// The client for one share. /// The client for one share.
pub fn client(&self, share_id: &str) -> Option<&ProtonDrivePublicLinkClient> { pub fn client(&self, share_id: &str) -> Option<&ShareClient> {
self.clients.get(share_id) self.clients.get(share_id)
} }
@@ -377,7 +778,7 @@ impl SharedLibrary {
.client(share_id) .client(share_id)
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?; .ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
let root = client.get_root_node().await?; let root = client.root_node().await?;
let mut level = vec![root.uid.clone()]; let mut level = vec![root.uid.clone()];
let mut found = vec![root]; let mut found = vec![root];
@@ -433,7 +834,7 @@ const APP_VERSION: &str = concat!("external-drive-stream@", env!("CARGO_PKG_VERS
const USER_AGENT: &str = concat!("proton-stream/", env!("CARGO_PKG_VERSION")); const USER_AGENT: &str = concat!("proton-stream/", env!("CARGO_PKG_VERSION"));
/// The API configuration every visitor client is built from. /// The API configuration every visitor client is built from.
fn client_configuration() -> ProtonClientConfiguration { pub(crate) fn client_configuration() -> ProtonClientConfiguration {
ProtonClientConfiguration::new(APP_VERSION).with_user_agent(USER_AGENT) ProtonClientConfiguration::new(APP_VERSION).with_user_agent(USER_AGENT)
} }
+966
View File
@@ -0,0 +1,966 @@
//! 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, ProtonDriveClient};
use proton_sdk::ids::NodeUid;
use serde::{Deserialize, Serialize};
use crate::account::Account;
use crate::catalog::{Catalog, WatchState};
use crate::config::{AppDirs, read_json, write_json};
use crate::error::{Error, Result};
/// The Drive device watch history is kept on.
pub const DEVICE_NAME: &str = "proton-stream";
/// The folder on that device holding one file per installation.
const FOLDER: &str = "watch-history";
/// Bumped only for a change an older build would misread. A file of a newer
/// version is skipped rather than half-understood.
const FORMAT_VERSION: u32 = 1;
/// One installation's watch history, as stored in Drive.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct HistoryFile {
version: u32,
installation: String,
entries: Vec<HistoryEntry>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct HistoryEntry {
share: String,
link: String,
position: f64,
#[serde(default)]
duration: Option<f64>,
watched: bool,
updated_at: i64,
}
impl HistoryEntry {
fn from_state((share, link): (String, String), state: WatchState) -> Self {
Self {
share,
link,
position: state.position_secs,
duration: state.duration_secs,
watched: state.watched,
updated_at: state.updated_at,
}
}
fn state(&self) -> WatchState {
WatchState {
position_secs: self.position,
duration_secs: self.duration,
watched: self.watched,
updated_at: self.updated_at,
}
}
/// Whether this entry could have come from this app. A file is another
/// device's to write, so its contents are checked before they reach the
/// catalog.
fn is_sane(&self) -> bool {
self.position.is_finite()
&& self.position >= 0.0
&& self
.duration
.is_none_or(|duration| duration.is_finite() && duration >= 0.0)
&& !self.share.is_empty()
&& !self.link.is_empty()
}
}
/// This installation's sync identity, kept beside the share list.
#[derive(Debug, Serialize, Deserialize)]
struct Identity {
installation: String,
}
/// What one [`WatchSync::sync`] did.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SyncReport {
/// Episodes whose position another device had newer.
pub applied: usize,
/// Whether this installation's file was written.
pub uploaded: bool,
/// Other installations whose history was read.
pub devices: usize,
/// Files of installations gone quiet that were moved to the trash, their
/// history now carried in this installation's own file.
pub retired: usize,
}
/// How long an installation's file may go unwritten before another
/// installation retires it.
///
/// Reinstalling — routine on Android, where uninstalling wipes the config —
/// starts a new id, and every file ever written would otherwise be read by
/// every device forever. Retiring is safe at any age: a file is only trashed
/// after its entries were merged and this installation's file, which then
/// holds them too, is written. An installation that was merely idle finds its
/// file gone on its next sync and writes it again.
const RETIRE_AFTER_SECS: i64 = 90 * 24 * 60 * 60;
/// One history file in Drive, as listed.
#[derive(Debug, Clone)]
pub(crate) struct RemoteFile {
pub uid: NodeUid,
pub name: String,
/// The active revision, which moves with every write.
pub revision: Option<String>,
/// Last modification, epoch seconds.
pub modified: i64,
}
/// The few Drive operations sync needs: the account's Drive in the app, an
/// in-memory one in the tests.
pub(crate) trait HistoryDrive: Sync {
/// The history folders, creating the device and folder the first time.
/// See [`WatchSync::folders`].
fn history_folders(&self) -> impl Future<Output = Result<Vec<NodeUid>>> + Send;
/// The untrashed `.json` files directly in `folder`.
fn list(&self, folder: &NodeUid) -> impl Future<Output = Result<Vec<RemoteFile>>> + Send;
fn download(&self, uid: &NodeUid) -> impl Future<Output = Result<Vec<u8>>> + Send;
fn create(
&self,
folder: &NodeUid,
name: &str,
bytes: &[u8],
) -> impl Future<Output = Result<()>> + Send;
fn replace(&self, uid: &NodeUid, bytes: &[u8]) -> impl Future<Output = Result<()>> + Send;
fn trash(&self, uids: &[NodeUid]) -> impl Future<Output = Result<()>> + Send;
}
impl HistoryDrive for ProtonDriveClient {
/// Two installations signing in at the same moment can each create the
/// device before seeing the other's. Every device of that name is read
/// from so neither's history is lost; the oldest is the one written to,
/// which is the same one on every installation.
async fn history_folders(&self) -> Result<Vec<NodeUid>> {
let mut devices: Vec<_> = self
.enumerate_devices()
.await?
.into_iter()
.filter(|device| device.name.as_deref().ok() == Some(DEVICE_NAME))
.collect();
devices.sort_by_key(|device| device.creation_time);
if devices.is_empty() {
devices.push(self.create_device(DEVICE_NAME, device_type()).await?);
}
let mut folders = Vec::with_capacity(devices.len());
for device in &devices {
folders.push(
self.create_folder_path(&device.root_folder_uid, FOLDER)
.await?,
);
}
Ok(folders)
}
async fn list(&self, folder: &NodeUid) -> Result<Vec<RemoteFile>> {
let uids = self.enumerate_folder_children_node_uids(folder).await?;
if uids.is_empty() {
return Ok(Vec::new());
}
Ok(self
.enumerate_nodes(&uids)
.await?
.into_iter()
.filter_map(|node| {
let NodeKind::File {
active_revision_id, ..
} = node.kind
else {
return None;
};
(!node.trashed && node.name.ends_with(".json")).then_some(RemoteFile {
uid: node.uid,
name: node.name,
revision: active_revision_id,
modified: node.modification_time,
})
})
.collect())
}
async fn download(&self, uid: &NodeUid) -> Result<Vec<u8>> {
Ok(self.download_file(uid).await?)
}
async fn create(&self, folder: &NodeUid, name: &str, bytes: &[u8]) -> Result<()> {
self.upload_file(folder, name, "application/json", bytes)
.await?;
Ok(())
}
async fn replace(&self, uid: &NodeUid, bytes: &[u8]) -> Result<()> {
Ok(self.upload_new_revision(uid, bytes).await?)
}
async fn trash(&self, uids: &[NodeUid]) -> Result<()> {
for (uid, outcome) in self.trash_nodes(uids).await? {
if let Err(error) = outcome {
tracing::warn!(%uid, %error, "a retired watch-history file was not trashed");
}
}
Ok(())
}
}
/// A history file as last read, by the revision it was read at. `None`
/// entries for one this build could not read.
struct Seen {
revision: Option<String>,
entries: Option<Vec<HistoryEntry>>,
}
/// Syncs the catalog's watch table with the account's Drive.
pub struct WatchSync {
installation: String,
/// Remote files by link id. A file whose active revision has not moved
/// since it was read is not downloaded again.
seen: Mutex<HashMap<String, Seen>>,
/// What this installation's own file holds, once known: what was read
/// back at the first sync, then what was last written. Compared against
/// the catalog so an unchanged history is not uploaded as a new revision.
own: Mutex<Option<Vec<HistoryEntry>>>,
/// The history folders, resolved once per account. Usually one; see
/// [`HistoryDrive::history_folders`].
folders: Mutex<Option<Vec<NodeUid>>>,
/// One sync at a time: two would race on creating the device and the
/// installation's file.
running: tokio::sync::Mutex<()>,
}
impl WatchSync {
/// This installation's sync, with its id read from — or, the first time,
/// written to — [`AppDirs::sync_file`].
pub fn open(dirs: &AppDirs) -> Result<Self> {
let path = dirs.sync_file();
let installation = match read_json::<Identity>(&path)? {
Some(identity) => identity.installation,
None => {
let identity = Identity {
installation: new_installation_id()?,
};
write_json(&path, &identity)?;
identity.installation
}
};
Ok(Self::with_installation(installation))
}
fn with_installation(installation: String) -> Self {
Self {
installation,
seen: Mutex::new(HashMap::new()),
own: Mutex::new(None),
folders: Mutex::new(None),
running: tokio::sync::Mutex::new(()),
}
}
/// Forget everything learned about the account, after signing out or in
/// as someone else.
pub fn reset(&self) {
self.seen.lock().clear();
*self.own.lock() = None;
*self.folders.lock() = None;
}
/// Pull every other installation's history into `catalog`, write this
/// one's if it changed, then retire the files of installations gone quiet.
///
/// The catalog is locked only around its own reads and writes, never
/// across a request, so playback can keep saving its position while this
/// runs.
pub async fn sync(&self, account: &Account, catalog: &Mutex<Catalog>) -> Result<SyncReport> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_secs() as i64);
self.sync_with(account.drive(), catalog, now).await
}
pub(crate) async fn sync_with<D: HistoryDrive>(
&self,
drive: &D,
catalog: &Mutex<Catalog>,
now: i64,
) -> Result<SyncReport> {
let _running = self.running.lock().await;
let folders = self.folders(drive).await?;
let own_name = format!("{}.json", self.installation);
let mut own_file = None;
let mut others = Vec::new();
for folder in &folders {
for file in drive.list(folder).await? {
if file.name == own_name {
own_file.get_or_insert(file);
} else {
others.push(file);
}
}
}
let mut remote = Vec::new();
// Files whose every entry has been read, and so can be retired once
// this installation's file carries them.
let mut readable = Vec::new();
for file in &others {
if let Some(entries) = self.read(drive, file).await {
remote.extend(entries);
readable.push(file);
}
}
// What this installation wrote before is history too: after a
// reinstall that kept the config, or a catalog that was deleted. One
// that cannot be read now is left alone rather than overwritten with
// less than it holds, and read again next time.
let mut own_unreadable = false;
if self.own.lock().is_none() {
let entries = match &own_file {
Some(file) => self.read(drive, file).await,
None => Some(Vec::new()),
};
match entries {
Some(entries) => {
remote.extend(entries.iter().cloned());
*self.own.lock() = Some(entries);
}
None => own_unreadable = true,
}
}
let incoming: Vec<(String, String, WatchState)> = remote
.iter()
.filter(|entry| entry.is_sane())
.map(|entry| (entry.share.clone(), entry.link.clone(), entry.state()))
.collect();
let (applied, local) = {
let mut catalog = catalog.lock();
let applied = catalog.merge_watch_states(&incoming)?;
(applied, catalog.all_watch_states()?)
};
let mut entries: Vec<HistoryEntry> = local
.into_iter()
.map(|(key, state)| HistoryEntry::from_state(key, state))
.collect();
entries.sort_by(|a, b| (&a.share, &a.link).cmp(&(&b.share, &b.link)));
// A file that is missing is written even when what it would hold has
// not changed: another installation retired it, or it was deleted by
// hand, and either way the history it carried lives only here now.
let unchanged = own_file.is_some() && self.own.lock().as_ref() == Some(&entries);
let uploaded = if own_unreadable || unchanged || (entries.is_empty() && own_file.is_none())
{
false
} 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(file) => drive.replace(&file.uid, &bytes).await?,
None => drive.create(&folders[0], &own_name, &bytes).await?,
}
*self.own.lock() = Some(entries);
true
};
// Only now: every entry of a retired file was merged above, and this
// installation's file — just written, or already up to date — holds
// the merged table.
let retired: Vec<NodeUid> = readable
.into_iter()
.filter(|file| !own_unreadable && now - file.modified >= RETIRE_AFTER_SECS)
.map(|file| file.uid.clone())
.collect();
if !retired.is_empty() {
match drive.trash(&retired).await {
Ok(()) => {
let mut seen = self.seen.lock();
for uid in &retired {
seen.remove(uid.link_id.as_str());
}
}
Err(error) => {
tracing::warn!(%error, "retiring old watch-history files failed");
}
}
}
Ok(SyncReport {
applied,
uploaded,
devices: others.len(),
retired: retired.len(),
})
}
/// The entries of one history file, from the cache when its revision has
/// not moved, or `None` when it cannot be read.
///
/// A file that will not download or parse is logged and skipped: it is
/// another device's, and one bad file must not stop the rest syncing.
async fn read<D: HistoryDrive>(
&self,
drive: &D,
file: &RemoteFile,
) -> Option<Vec<HistoryEntry>> {
let key = file.uid.link_id.as_str().to_owned();
if let Some(seen) = self.seen.lock().get(&key)
&& file.revision.is_some()
&& seen.revision == file.revision
{
return seen.entries.clone();
}
let uid = &file.uid;
let entries = match drive.download(uid).await {
Ok(bytes) => {
let entries = parse(&bytes);
if entries.is_none() {
tracing::warn!(%uid, "skipping a watch-history file this build cannot read");
}
entries
}
Err(error) => {
// Not cached: a download that failed is tried again next time.
tracing::warn!(%uid, %error, "a watch-history file could not be downloaded");
return None;
}
};
self.seen.lock().insert(
key,
Seen {
revision: file.revision.clone(),
entries: entries.clone(),
},
);
entries
}
/// The history folders, resolved once per account.
async fn folders<D: HistoryDrive>(&self, drive: &D) -> Result<Vec<NodeUid>> {
if let Some(folders) = self.folders.lock().clone() {
return Ok(folders);
}
let folders = drive.history_folders().await?;
*self.folders.lock() = Some(folders.clone());
Ok(folders)
}
}
/// A history file's entries, or `None` for one this build cannot read.
fn parse(bytes: &[u8]) -> Option<Vec<HistoryEntry>> {
let file: HistoryFile = serde_json::from_slice(bytes).ok()?;
(file.version <= FORMAT_VERSION).then_some(file.entries)
}
/// The kind of device to register, which is only the icon the web app draws.
fn device_type() -> DeviceType {
if cfg!(target_os = "windows") {
DeviceType::Windows
} else if cfg!(target_os = "macos") {
DeviceType::MacOs
} else {
DeviceType::Linux
}
}
fn new_installation_id() -> Result<String> {
let mut bytes = [0_u8; 16];
getrandom::fill(&mut bytes)
.map_err(|e| Error::Config(format!("no randomness for an installation id: {e}")))?;
Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(link: &str, position: f64, updated_at: i64) -> HistoryEntry {
HistoryEntry {
share: "share-a".to_owned(),
link: link.to_owned(),
position,
duration: Some(1440.0),
watched: false,
updated_at,
}
}
/// One history folder in memory, standing in for the account's Drive.
#[derive(Default)]
struct FakeDrive {
files: Mutex<Vec<FakeFile>>,
/// What the next write is stamped with, epoch seconds.
clock: std::sync::atomic::AtomicI64,
/// Downloads fail while set, as on a dropped connection.
offline: std::sync::atomic::AtomicBool,
uploads: std::sync::atomic::AtomicUsize,
}
struct FakeFile {
uid: NodeUid,
name: String,
bytes: Vec<u8>,
revision: u32,
modified: i64,
trashed: bool,
}
fn uid(link: &str) -> NodeUid {
NodeUid::new("volume".to_owned().into(), link.to_owned().into())
}
impl FakeDrive {
fn at(&self, now: i64) {
self.clock.store(now, std::sync::atomic::Ordering::SeqCst);
}
fn names(&self) -> Vec<String> {
let mut names: Vec<_> = self
.files
.lock()
.iter()
.filter(|file| !file.trashed)
.map(|file| file.name.clone())
.collect();
names.sort();
names
}
fn uploads(&self) -> usize {
self.uploads.load(std::sync::atomic::Ordering::SeqCst)
}
fn put(&self, name: &str, bytes: Vec<u8>) {
let modified = self.clock.load(std::sync::atomic::Ordering::SeqCst);
let mut files = self.files.lock();
let link = format!("file-{}", files.len());
files.push(FakeFile {
uid: uid(&link),
name: name.to_owned(),
bytes,
revision: 1,
modified,
trashed: false,
});
}
}
impl HistoryDrive for FakeDrive {
async fn history_folders(&self) -> Result<Vec<NodeUid>> {
Ok(vec![uid("folder")])
}
async fn list(&self, _folder: &NodeUid) -> Result<Vec<RemoteFile>> {
Ok(self
.files
.lock()
.iter()
.filter(|file| !file.trashed)
.map(|file| RemoteFile {
uid: file.uid.clone(),
name: file.name.clone(),
revision: Some(file.revision.to_string()),
modified: file.modified,
})
.collect())
}
async fn download(&self, uid: &NodeUid) -> Result<Vec<u8>> {
if self.offline.load(std::sync::atomic::Ordering::SeqCst) {
return Err(Error::NotFound("offline".to_owned()));
}
self.files
.lock()
.iter()
.find(|file| &file.uid == uid)
.map(|file| file.bytes.clone())
.ok_or_else(|| Error::NotFound(uid.to_string()))
}
async fn create(&self, _folder: &NodeUid, name: &str, bytes: &[u8]) -> Result<()> {
self.uploads
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.put(name, bytes.to_vec());
Ok(())
}
async fn replace(&self, uid: &NodeUid, bytes: &[u8]) -> Result<()> {
self.uploads
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let modified = self.clock.load(std::sync::atomic::Ordering::SeqCst);
let mut files = self.files.lock();
let file = files
.iter_mut()
.find(|file| &file.uid == uid)
.ok_or_else(|| Error::NotFound(uid.to_string()))?;
file.bytes = bytes.to_vec();
file.revision += 1;
file.modified = modified;
Ok(())
}
async fn trash(&self, uids: &[NodeUid]) -> Result<()> {
for file in self.files.lock().iter_mut() {
if uids.contains(&file.uid) {
file.trashed = true;
}
}
Ok(())
}
}
/// An installation: its sync and its catalog.
fn device(id: &str) -> (WatchSync, Mutex<Catalog>) {
(
WatchSync::with_installation(id.to_owned()),
Mutex::new(Catalog::in_memory().unwrap()),
)
}
fn watched_at(position_secs: f64, updated_at: i64) -> WatchState {
WatchState {
position_secs,
duration_secs: Some(1440.0),
watched: false,
updated_at,
}
}
const DAY: i64 = 24 * 60 * 60;
#[tokio::test]
async fn a_position_saved_on_one_device_resumes_on_another() {
let drive = FakeDrive::default();
let (laptop, laptop_catalog) = device("laptop");
let (phone, phone_catalog) = device("phone");
laptop_catalog
.lock()
.set_watch_state("share-a", "ep1", &watched_at(600.0, 100))
.unwrap();
let first = laptop
.sync_with(&drive, &laptop_catalog, 100)
.await
.unwrap();
assert!(first.uploaded);
let second = phone.sync_with(&drive, &phone_catalog, 110).await.unwrap();
assert_eq!(second.applied, 1);
assert_eq!(second.devices, 1);
assert_eq!(
phone_catalog.lock().watch_state("share-a", "ep1").unwrap(),
Some(watched_at(600.0, 100))
);
assert_eq!(drive.names(), ["laptop.json", "phone.json"]);
}
#[tokio::test]
async fn the_newer_position_wins_whichever_device_syncs_first() {
let drive = FakeDrive::default();
let (laptop, laptop_catalog) = device("laptop");
let (phone, phone_catalog) = device("phone");
laptop_catalog
.lock()
.set_watch_state("share-a", "ep1", &watched_at(300.0, 100))
.unwrap();
phone_catalog
.lock()
.set_watch_state("share-a", "ep1", &watched_at(900.0, 200))
.unwrap();
laptop
.sync_with(&drive, &laptop_catalog, 300)
.await
.unwrap();
phone.sync_with(&drive, &phone_catalog, 300).await.unwrap();
laptop
.sync_with(&drive, &laptop_catalog, 300)
.await
.unwrap();
for catalog in [&laptop_catalog, &phone_catalog] {
assert_eq!(
catalog.lock().watch_state("share-a", "ep1").unwrap(),
Some(watched_at(900.0, 200))
);
}
}
#[tokio::test]
async fn an_unchanged_history_is_not_uploaded_again() {
let drive = FakeDrive::default();
let (laptop, catalog) = device("laptop");
catalog
.lock()
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
.unwrap();
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
let again = laptop.sync_with(&drive, &catalog, 200).await.unwrap();
assert!(!again.uploaded);
assert_eq!(drive.uploads(), 1);
}
#[tokio::test]
async fn nothing_is_uploaded_before_anything_was_watched() {
let drive = FakeDrive::default();
let (laptop, catalog) = device("laptop");
let report = laptop.sync_with(&drive, &catalog, 100).await.unwrap();
assert!(!report.uploaded);
assert!(drive.names().is_empty());
}
#[tokio::test]
async fn a_file_that_went_missing_is_written_again() {
let drive = FakeDrive::default();
let (laptop, catalog) = device("laptop");
catalog
.lock()
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
.unwrap();
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
let own: Vec<NodeUid> = drive.files.lock().iter().map(|f| f.uid.clone()).collect();
drive.trash(&own).await.unwrap();
let report = laptop.sync_with(&drive, &catalog, 200).await.unwrap();
assert!(report.uploaded);
assert_eq!(drive.names(), ["laptop.json"]);
}
#[tokio::test]
async fn history_this_installation_wrote_before_comes_back_into_an_empty_catalog() {
let drive = FakeDrive::default();
let (laptop, catalog) = device("laptop");
catalog
.lock()
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
.unwrap();
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
// The catalog was deleted; the config, and with it the id, was not.
let (relaunched, empty) = device("laptop");
let report = relaunched.sync_with(&drive, &empty, 200).await.unwrap();
assert_eq!(report.applied, 1);
assert!(!report.uploaded);
assert_eq!(
empty.lock().watch_state("share-a", "ep1").unwrap(),
Some(watched_at(60.0, 100))
);
}
#[tokio::test]
async fn an_own_file_that_cannot_be_read_is_not_overwritten() {
let drive = FakeDrive::default();
let (laptop, catalog) = device("laptop");
catalog
.lock()
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
.unwrap();
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
let (relaunched, other) = device("laptop");
other
.lock()
.set_watch_state("share-a", "ep2", &watched_at(5.0, 150))
.unwrap();
drive
.offline
.store(true, std::sync::atomic::Ordering::SeqCst);
let report = relaunched.sync_with(&drive, &other, 200).await.unwrap();
assert!(!report.uploaded);
drive
.offline
.store(false, std::sync::atomic::Ordering::SeqCst);
relaunched.sync_with(&drive, &other, 300).await.unwrap();
let state = other.lock().all_watch_states().unwrap();
assert_eq!(state.len(), 2, "both episodes survive: {state:?}");
}
#[tokio::test]
async fn a_file_from_a_newer_build_is_neither_merged_nor_retired() {
let drive = FakeDrive::default();
let future = HistoryFile {
version: FORMAT_VERSION + 1,
installation: "future".to_owned(),
entries: vec![entry("ep1", 60.0, 100)],
};
drive.put("future.json", serde_json::to_vec(&future).unwrap());
let (laptop, catalog) = device("laptop");
catalog
.lock()
.set_watch_state("share-a", "ep2", &watched_at(5.0, 100))
.unwrap();
let report = laptop
.sync_with(&drive, &catalog, RETIRE_AFTER_SECS + DAY)
.await
.unwrap();
assert_eq!(report.applied, 0);
assert_eq!(report.retired, 0);
assert_eq!(drive.names(), ["future.json", "laptop.json"]);
}
#[tokio::test]
async fn a_quiet_installations_file_is_retired_once_its_history_is_carried() {
let drive = FakeDrive::default();
let (old_phone, old_catalog) = device("old-phone");
old_catalog
.lock()
.set_watch_state("share-a", "ep1", &watched_at(600.0, 100))
.unwrap();
drive.at(100);
old_phone
.sync_with(&drive, &old_catalog, 100)
.await
.unwrap();
let (phone, catalog) = device("phone");
let later = 100 + RETIRE_AFTER_SECS;
drive.at(later);
let report = phone.sync_with(&drive, &catalog, later).await.unwrap();
assert_eq!(report.retired, 1);
assert_eq!(drive.names(), ["phone.json"]);
// Nothing was lost: the newcomer's file carries it now.
let (fresh, fresh_catalog) = device("fresh");
fresh
.sync_with(&drive, &fresh_catalog, later)
.await
.unwrap();
assert_eq!(
fresh_catalog.lock().watch_state("share-a", "ep1").unwrap(),
Some(watched_at(600.0, 100))
);
}
#[tokio::test]
async fn a_recent_installations_file_is_kept() {
let drive = FakeDrive::default();
let (laptop, laptop_catalog) = device("laptop");
laptop_catalog
.lock()
.set_watch_state("share-a", "ep1", &watched_at(600.0, 100))
.unwrap();
drive.at(100);
laptop
.sync_with(&drive, &laptop_catalog, 100)
.await
.unwrap();
let (phone, catalog) = device("phone");
let report = phone
.sync_with(&drive, &catalog, 100 + RETIRE_AFTER_SECS - DAY)
.await
.unwrap();
assert_eq!(report.retired, 0);
assert_eq!(drive.names(), ["laptop.json", "phone.json"]);
}
#[test]
fn an_installation_keeps_the_id_it_was_given() {
let root =
std::env::temp_dir().join(format!("pstr-sync-{}", new_installation_id().unwrap()));
let dirs = AppDirs::from_paths(root.join("c"), root.join("d"), root.join("k")).unwrap();
let first = WatchSync::open(&dirs).unwrap().installation;
let second = WatchSync::open(&dirs).unwrap().installation;
assert_eq!(first, second);
assert_eq!(first.len(), 32);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn a_history_file_round_trips() {
let file = HistoryFile {
version: FORMAT_VERSION,
installation: "abc".to_owned(),
entries: vec![entry("a", 12.5, 100)],
};
let bytes = serde_json::to_vec(&file).unwrap();
assert_eq!(parse(&bytes), Some(file.entries));
}
#[test]
fn a_history_file_from_a_newer_format_is_not_read() {
let file = HistoryFile {
version: FORMAT_VERSION + 1,
installation: "abc".to_owned(),
entries: vec![entry("a", 12.5, 100)],
};
assert_eq!(parse(&serde_json::to_vec(&file).unwrap()), None);
assert_eq!(parse(b"not json"), None);
}
#[test]
fn entries_another_device_could_not_have_written_are_refused() {
assert!(entry("a", 1.0, 1).is_sane());
assert!(!entry("a", f64::NAN, 1).is_sane());
assert!(!entry("a", -1.0, 1).is_sane());
assert!(!entry("", 1.0, 1).is_sane());
let mut infinite = entry("a", 1.0, 1);
infinite.duration = Some(f64::INFINITY);
assert!(!infinite.is_sane());
}
#[test]
fn an_entry_carries_its_watch_state_unchanged() {
let state = WatchState {
position_secs: 61.0,
duration_secs: None,
watched: true,
updated_at: 42,
};
let entry = HistoryEntry::from_state(("s".to_owned(), "l".to_owned()), state);
assert_eq!(entry.state(), state);
}
}
+16
View File
@@ -421,6 +421,22 @@ impl MetadataService {
} }
} }
/// Pin `title` to the entry the viewer picked, and enrich it.
///
/// Both halves, always: the point of choosing an entry by hand is usually
/// that the episode names were wrong too, so a choice that stored only the
/// title record left the episodes, the backdrop and the franchise chain of
/// the *old* match — or none — until some later match run got round to it.
/// A failed enrichment still keeps the choice, as in [`Self::work`].
pub async fn choose(&self, title: &Title, found: TitleMetadata) -> Outcome {
let enrichment = self.enrich_or_log(title, &found).await;
Outcome {
title_key: title.key.clone(),
record: Some(self.chosen(title.key.clone(), found)),
enrichment,
}
}
async fn enrich_or_log(&self, title: &Title, found: &TitleMetadata) -> Option<Enrichment> { async fn enrich_or_log(&self, title: &Title, found: &TitleMetadata) -> Option<Enrichment> {
self.enrich(title, found) self.enrich(title, found)
.await .await
+1 -1
View File
@@ -42,4 +42,4 @@ pub use proton_sdk::ids::NodeUid;
pub use reader::{FileBlocks, LibraryOpener, RevisionBlocks}; pub use reader::{FileBlocks, LibraryOpener, RevisionBlocks};
pub use ring::{DEFAULT_RING_BYTES, RingStats}; pub use ring::{DEFAULT_RING_BYTES, RingStats};
pub use source::{RevisionOpener, StreamConfig, StreamSource}; pub use source::{RevisionOpener, StreamConfig, StreamSource};
pub use stream::{DEFAULT_READAHEAD_BLOCKS, StreamStats, VideoStream}; pub use stream::{COPY_BLOCKS_IN_FLIGHT, DEFAULT_READAHEAD_BLOCKS, StreamStats, VideoStream};
+1 -2
View File
@@ -13,7 +13,6 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use proton_drive_rs::ProtonDrivePublicLinkClient;
use proton_sdk::error::ProtonError; use proton_sdk::error::ProtonError;
use proton_sdk::ids::NodeUid; use proton_sdk::ids::NodeUid;
@@ -159,7 +158,7 @@ impl LibraryOpener {
Self { library } Self { library }
} }
fn client(&self, share_id: &str) -> Result<&ProtonDrivePublicLinkClient> { fn client(&self, share_id: &str) -> Result<&pstr_core::ShareClient> {
self.library self.library
.client(share_id) .client(share_id)
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open"))) .ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))
+42
View File
@@ -63,6 +63,12 @@ const MAX_CONCURRENT_BLOCKS_PER_READ: usize = 4;
/// bandwidth on cancelled prefetches. Well past a 1080p bitrate either way. /// bandwidth on cancelled prefetches. Well past a 1080p bitrate either way.
pub const DEFAULT_READAHEAD_BLOCKS: usize = 12; pub const DEFAULT_READAHEAD_BLOCKS: usize = 12;
/// How many blocks of one offline copy are fetched at once, for
/// [`VideoStream::blocks_in_order`]. The same as one read is allowed, for the
/// same reason: enough to hide a round trip, few enough that a couple of
/// copies running together do not swamp a player.
pub const COPY_BLOCKS_IN_FLIGHT: usize = 4;
/// How far a read may land from where the previous one ended and still count as /// How far a read may land from where the previous one ended and still count as
/// a continuation rather than a seek. /// a continuation rather than a seek.
/// ///
@@ -299,6 +305,42 @@ impl VideoStream {
Ok(buf) Ok(buf)
} }
/// Every block from `first` on, fetched `in_flight` at a time and yielded
/// in file order.
///
/// What an offline copy reads through. File order is what a partial file's
/// "its length is a block boundary" resume rule needs, and a few blocks in
/// flight is what keeps a copy from running one round trip per block. Use
/// it on a stream from [`crate::StreamSource::open_for_copy`]: a playback
/// stream would add its own read-ahead on top.
pub fn blocks_in_order(
&self,
first: usize,
in_flight: usize,
) -> futures::stream::BoxStream<'static, Result<Vec<u8>>> {
let start: u64 = self.block_sizes()[..first.min(self.block_sizes().len())]
.iter()
.sum();
let ranges: Vec<(u64, u64)> = self
.block_sizes()
.iter()
.skip(first)
.scan(start, |offset, size| {
let range = (*offset, *size);
*offset += size;
Some(range)
})
.collect();
let stream = self.clone();
stream::iter(ranges)
.map(move |(offset, size)| {
let stream = stream.clone();
async move { stream.read_range(offset, size).await }
})
.buffered(in_flight.max(1))
.boxed()
}
pub fn stats(&self) -> StreamStats { pub fn stats(&self) -> StreamStats {
StreamStats { StreamStats {
ring: self.inner.ring.stats(), ring: self.inner.ring.stats(),
+22 -1
View File
@@ -104,6 +104,24 @@ an edge case.
holds only the id, display name and token. holds only the id, display name and token.
- Nothing this app holds can write to the share: a viewer link grants - Nothing this app holds can write to the share: a viewer link grants
`MemberRole::Viewer`, and the SDK's public-link write side is unported anyway. `MemberRole::Viewer`, and the SDK's public-link write side is unported anyway.
- Signing in to a Proton account is optional (`pstr-core::account`). The
session — tokens, and per account key the passphrase that unlocks it,
derived at sign-in — is one blob in the same credential store, under
`proton-account`. The mailbox password itself is never stored. Only TOTP
second factors are spoken; an account whose only one is a security key is
refused with a reason before the code prompt. Refresh tokens are single-use, so
every refresh is written back. On the desktop the blob can outgrow one
credential entry — Windows caps one at 1280 UTF-16 units — so
`KeyringSecretStore` splits a long value across `key#0`, `key#1`, … under a
header in `key`. The account writes exactly one kind of thing: the
watch-history files on its own `proton-stream` device (`pstr-core::sync`),
including trashing the file of an installation quiet for 90 days once its
history is merged into this one's. Folders of the account added as shares
are only ever read.
- A CAPTCHA in front of a sign-in is shown in a webview, because the page
reports success by posting a message to its host. On the desktop that is a
child process of the app (`pstr-app::verify`, wry): WebKitGTK on Linux,
WebView2 on Windows. It may only navigate to `https://*.proton.me`.
- Signature verification is absent on the visitor path by design — an anonymous - Signature verification is absent on the visitor path by design — an anonymous
visitor cannot read `core/v4/keys/all` to resolve a signer. Decryption still visitor cannot read `core/v4/keys/all` to resolve a signer. Decryption still
proves the bytes were encrypted to the node key. Nodes come back with nothing proves the bytes were encrypted to the node key. Nodes come back with nothing
@@ -118,7 +136,10 @@ an edge case.
| Share list | config dir, `shares.json` | **No** — atomic writes, never overwritten when unparseable | | Share list | config dir, `shares.json` | **No** — atomic writes, never overwritten when unparseable |
| Share secrets | OS credential store | **No** | | Share secrets | OS credential store | **No** |
| Catalog | data dir, `catalog.db` | Yes — recrawl | | Catalog | data dir, `catalog.db` | Yes — recrawl |
| Watch state | same DB, own table | **No** — deliberately survives a recrawl | | Watch state | same DB, own table | **No** — deliberately survives a recrawl and removing the share; with an account, mirrored to its Drive |
| Proton session | OS credential store, `proton-account` | No — sign in again |
| Sync identity | config dir, `sync.json` | A new id if missing — the old history file is still read |
| Watch history in Drive | device `proton-stream`, `watch-history/<id>.json` | One file per installation; merged per episode, newer wins. A file unwritten for 90 days is retired by the next installation to sync, after merging it; a live installation whose file was retired writes it again |
| Block cache | cache dir, `blocks/` | Yes — must work correctly after deletion | | Block cache | cache dir, `blocks/` | Yes — must work correctly after deletion |
| Poster thumbnails | cache dir, `thumbs/` | Yes — refetched per file, one small decrypt each; stored scaled to 640 px | | Poster thumbnails | cache dir, `thumbs/` | Yes — refetched per file, one small decrypt each; stored scaled to 640 px |
| Provider artwork | cache dir, `posters/` | Yes — refetched by URL; stored scaled to 640 px | | Provider artwork | cache dir, `posters/` | Yes — refetched by URL; stored scaled to 640 px |
+74
View File
@@ -146,6 +146,80 @@ and [B44](#b44--r8-broke-the-app-under-instrumentation-in-five-places).
## Fixed ## Fixed
### B64 — Android offline downloads looked stuck
**Symptom.** A queued episode, and more so a queued season, sat at a progress
bar that did not move for long stretches, then jumped.
**Cause.** Three, compounding. WorkManager starts every queued worker whose
constraints are met, so "Download season" was a dozen transfers at once, each
with a share of the link. Each read through the *playback* stream, so behind
every block it read sat twelve blocks of read-ahead competing for the same
bandwidth — and filling the shared memory ring and the disk block cache, a
second copy of every byte. And progress was reported once per completed
in-order block, so the first report waited on all of that. The desktop had none
of these: three downloads at most, a copy stream with no read-ahead, four
blocks in flight.
**Fix.** The Android engine allows two downloads to transfer at once; the rest
wait for a slot, reported as queued (the worker no longer marks itself running
before its first progress report, and its notification says "Waiting to
download"). Downloads open through `StreamSource::open_for_copy` and read with
`VideoStream::blocks_in_order` at `COPY_BLOCKS_IN_FLIGHT`, now shared with the
desktop engine. The rows show a smoothed transfer rate beside the bytes, so a
slow download reads as slow rather than stuck.
**Verified.** Workspace gate and the Android host check pass, with
`TransferRateTest` covering the rate. Not yet observed on a device.
### B63 — a taskbar pin on Windows lost its icon
**Symptom.** Proton Stream pinned to the Windows taskbar showed its icon at
first, then after a while — reliably after an update — the generic program
icon.
**Cause.** Two, each enough on its own. `proton-stream.exe` carried no icon
resource: the window sets one at runtime, which is what a pin shows while the
icon cache still holds it, but a pin made from the running window points at
the exe, and once the cache is rebuilt the exe has nothing to give. A pin made
from the Start Menu shortcut instead took the shortcut's `Icon`, which Windows
Installer keeps in `C:\Windows\Installer\{ProductCode}`. Every upgrade has a
new ProductCode and deletes the old product's folder, icon included.
**Fix.** `crates/pstr-app/build.rs` compiles `proton-stream.rc`, which embeds
`packaging/windows/proton-stream.ico`, into the GUI exe: `rc.exe` on MSVC,
mingw's `windres` on the cross build. Both installer definitions drop the
shortcut's `Icon` (and the WiX one `Advertise`), so the shortcut shows the
exe's icon too. A pin that already lost its icon keeps the dead reference;
unpin and pin again once.
**Verified.** `objdump -p` on the cross-built exe lists the `RT_ICON` entries,
all seven sizes. `msiinfo export … Shortcut` on a wixl build has an empty
`Icon_` column. Not yet checked on a Windows taskbar across an upgrade.
### B62 — an MSI upgrade left the app without libmpv
**Symptom.** Installing a new `.msi` over an old one ended with an app that
would not start for want of mpv. Installing the same `.msi` a second time
fixed it.
**Cause.** Both installers remove the old product early — the wixl one at
`RemoveExistingProducts` after `InstallInitialize`, WiX's `MajorUpgrade` by
default after `InstallValidate` — but costing runs before either, at
`CostFinalize`. Costing found the old `libmpv-2.dll` on disk with the same
version as the new one, since mpv is pinned, and decided not to copy it. Then
removing the old product deleted it. The second install found no DLL on disk,
so it copied one.
**Fix.** `REINSTALLMODE=amus` in both installer definitions, which copies every
file whatever is on disk. Every file is private to the install directory, so
the forced overwrite can downgrade nothing shared.
**Verified.** `msiinfo export … InstallExecuteSequence` on a wixl build shows
`FileCost` 900 and `CostFinalize` 1000 ahead of `RemoveExistingProducts` 1501,
and `msiinfo export … Property` has `REINSTALLMODE amus`. Not yet checked as an
upgrade on Windows.
### B61 — later seasons were captioned with season one's episode names ### B61 — later seasons were captioned with season one's episode names
**Symptom.** On a 42-title AniList library, Attack on Titan seasons 2–4 and **Symptom.** On a 42-title AniList library, Attack on Titan seasons 2–4 and
+4 -1
View File
@@ -161,7 +161,10 @@ reads an external subtitle through the same protocol); the work is in
## Design decisions already taken ## Design decisions already taken
- **Fat client, no server.** Each viewer's app talks to Proton directly. - **Fat client, no server.** Each viewer's app talks to Proton directly.
- **Public links only**, but several, merged into one catalog. No account login. - **Public links first**, several, merged into one catalog. A Proton account
is optional: it adds folders of the viewer's own Drive as shares, and watch
history synced through a `proton-stream` device in that Drive — one file per
installation, so no two devices ever write the same file.
- **Embedded libmpv**, not a bundled decoder stack. HEVC 10-bit, HDR and ASS - **Embedded libmpv**, not a bundled decoder stack. HEVC 10-bit, HDR and ASS
subtitles with hardware decode, for free. subtitles with hardware decode, for free.
- **No transcoding.** mpv demuxes and decodes natively; there is no remux or - **No transcoding.** mpv demuxes and decodes natively; there is no remux or
+2 -2
View File
@@ -14,7 +14,7 @@
# builds from the registry alone. # builds from the registry alone.
pkgname=proton-stream pkgname=proton-stream
pkgver=1.1.2 pkgver=1.2.0
pkgrel=1 pkgrel=1
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share" pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
arch=('x86_64') arch=('x86_64')
@@ -23,7 +23,7 @@ license=('MIT')
# mpv carries libmpv.so plus its headers on Arch, so it is both the runtime and # mpv carries libmpv.so plus its headers on Arch, so it is both the runtime and
# the build dependency. libsecret is the Secret Service side of the keyring — # the build dependency. libsecret is the Secret Service side of the keyring —
# the share fragment and custom password never touch shares.json. # the share fragment and custom password never touch shares.json.
depends=('mpv' 'libsecret' 'hicolor-icon-theme') depends=('mpv' 'libsecret' 'webkit2gtk-4.1' 'hicolor-icon-theme')
makedepends=('cargo') makedepends=('cargo')
optdepends=('gnome-keyring: credential storage via Secret Service' optdepends=('gnome-keyring: credential storage via Secret Service'
'kwallet: credential storage via Secret Service on KDE') 'kwallet: credential storage via Secret Service on KDE')
+16 -6
View File
@@ -40,6 +40,7 @@ are not Arch.
|---|---| |---|---|
| **libmpv ≥ 0.34** | The player. `pstr-player` links it directly and drives it through `stream_cb`; nothing here shells out to `mpv(1)`, so packages depend on the library, not the player binary. | | **libmpv ≥ 0.34** | The player. `pstr-player` links it directly and drives it through `stream_cb`; nothing here shells out to `mpv(1)`, so packages depend on the library, not the player binary. |
| **libsecret / Secret Service** | The share URL fragment and the custom password are secrets and live in the OS credential store, never in `shares.json`. | | **libsecret / Secret Service** | The share URL fragment and the custom password are secrets and live in the OS credential store, never in `shares.json`. |
| **WebKitGTK 4.1** | The window Proton's CAPTCHA is shown in when it gates a sign-in (`pstr-app::verify`). The page reports success by posting a message to its host, so the system browser cannot stand in for it. |
| **libGL, libxkbcommon, libX11, libwayland-client** | eframe on glow, with both display backends compiled in. | | **libGL, libxkbcommon, libX11, libwayland-client** | eframe on glow, with both display backends compiled in. |
Not dependencies, deliberately: **SQLite** (rusqlite is `bundled`) and Not dependencies, deliberately: **SQLite** (rusqlite is `bundled`) and
@@ -47,11 +48,13 @@ Not dependencies, deliberately: **SQLite** (rusqlite is `bundled`) and
Per packager that is: Per packager that is:
- Debian — `libmpv2 | libmpv1` (trixie | bookworm), `libsecret-1-0`, `libgl1`, - Debian — `libmpv2 | libmpv1` (trixie | bookworm), `libsecret-1-0`,
`libxkbcommon0`, `libx11-6`, `libwayland-client0`. `libwebkit2gtk-4.1-0`, `libgl1`, `libxkbcommon0`, `libx11-6`,
- RPM — `mpv-libs`, `libsecret`, `mesa-libGL`, `libxkbcommon`, `libX11`, `libwayland-client0`.
`libwayland-client`. - RPM — `mpv-libs`, `libsecret`, `webkit2gtk4.1`, `mesa-libGL`, `libxkbcommon`,
- Arch — `mpv` (which carries both `libmpv.so` and its headers), `libsecret`. `libX11`, `libwayland-client`.
- Arch — `mpv` (which carries both `libmpv.so` and its headers), `libsecret`,
`webkit2gtk-4.1`.
The `.deb` and `.rpm` are built from binaries compiled on the packaging host, so The `.deb` and `.rpm` are built from binaries compiled on the packaging host, so
they inherit its glibc. A package built on Arch installs on Fedora rawhide but they inherit its glibc. A package built on Arch installs on Fedora rawhide but
@@ -88,6 +91,13 @@ There is no static libmpv worth shipping, so **"standalone exe" means the exe
plus `libmpv-2.dll` beside it** — that pair, plus `pstr.exe` and the README, is plus `libmpv-2.dll` beside it** — that pair, plus `pstr.exe` and the README, is
what the portable `.zip` contains and what the `.msi` installs. what the portable `.zip` contains and what the `.msi` installs.
The cross build adds `WebView2Loader.dll` to that set. The CAPTCHA window runs
on WebView2, and under `-gnu` `webview2-com-sys` links its loader as a DLL —
without it beside the exe, the app does not start at all. `build-windows.sh`
takes it from that crate's build output. An `-msvc` build links the loader
statically and needs no DLL. The WebView2 *runtime* is part of Windows 10 and
11 and is not shipped.
The MSI needs the WiX toolset; the script installs it (`dotnet tool install The MSI needs the WiX toolset; the script installs it (`dotnet tool install
--global wix`) and adds the UI extension if they are missing. It installs to --global wix`) and adds the UI extension if they are missing. It installs to
`Program Files\Proton Stream`, puts a Start Menu shortcut on the GUI, and adds `Program Files\Proton Stream`, puts a Start Menu shortcut on the GUI, and adds
@@ -124,7 +134,7 @@ The `.icns` is rasterized from `io.narl.proton-stream.svg` when `rsvg-convert` o
| `io.narl.proton-stream.desktop` / `.svg` | Freedesktop entry and icon | | `io.narl.proton-stream.desktop` / `.svg` | Freedesktop entry and icon |
| `macos/Info.plist.in` | `.app` bundle plist; `@VERSION@`/`@BIN@`/`@APP_ID@` substituted at build time | | `macos/Info.plist.in` | `.app` bundle plist; `@VERSION@`/`@BIN@`/`@APP_ID@` substituted at build time |
| `windows/proton-stream.wxs` | WiX v4+ installer definition | | `windows/proton-stream.wxs` | WiX v4+ installer definition |
| `windows/proton-stream.ico` | MSI and shortcut icon, rasterized from the SVG | | `windows/proton-stream.ico` | Exe and Add/Remove Programs icon, rasterized from the SVG; `crates/pstr-app/build.rs` compiles it into `proton-stream.exe` |
| `windows/license.rtf` | Fallback for the installer's license dialog; the repo's own `LICENSE` is preferred when it exists | | `windows/license.rtf` | Fallback for the installer's license dialog; the repo's own `LICENSE` is preferred when it exists |
The Debian `control` file has no template — `scripts/build.sh` writes it, because The Debian `control` file has no template — `scripts/build.sh` writes it, because
+1
View File
@@ -31,6 +31,7 @@ ExclusiveArch: x86_64
# `bundled`) and TLS is rustls, so neither shows up here. # `bundled`) and TLS is rustls, so neither shows up here.
Requires: mpv-libs >= 0.34 Requires: mpv-libs >= 0.34
Requires: libsecret Requires: libsecret
Requires: webkit2gtk4.1
Requires: mesa-libGL Requires: mesa-libGL
Requires: libxkbcommon Requires: libxkbcommon
Requires: libX11 Requires: libX11
+25 -3
View File
@@ -78,6 +78,15 @@
<RemoveExistingProducts After="InstallInitialize" /> <RemoveExistingProducts After="InstallInitialize" />
</InstallExecuteSequence> </InstallExecuteSequence>
<!-- Copy every file on an upgrade, whatever is on disk. Costing runs
before RemoveExistingProducts, so it sees the old libmpv-2.dll — the
same version, since mpv is pinned — and decides not to copy it. The
removal of the old product then deletes it, and the upgrade ends with
no DLL: "mpv missing" until a second install, which finds nothing on
disk and copies it. Every file here is private to INSTALLDIR, so
overwriting unconditionally downgrades nothing shared. -->
<Property Id="REINSTALLMODE" Value="amus" />
<Media Id="1" Cabinet="proton_stream.cab" EmbedCab="yes" /> <Media Id="1" Cabinet="proton_stream.cab" EmbedCab="yes" />
<Icon Id="proton-stream.ico" SourceFile="$(var.IconFile)" /> <Icon Id="proton-stream.ico" SourceFile="$(var.IconFile)" />
@@ -98,14 +107,19 @@
and writes the advertised form anyway, with the feature name in and writes the advertised form anyway, with the feature name in
Shortcut/Target. What selects the non-advertised form is being Shortcut/Target. What selects the non-advertised form is being
a child of the Component rather than of the File, plus an a child of the Component rather than of the File, plus an
explicit formatted Target. Keep it out of the <File>. --> explicit formatted Target. Keep it out of the <File>.
No Icon either. A shortcut's Icon is copied to
C:\Windows\Installer\{ProductCode}, and every upgrade has a
new ProductCode and deletes the old folder — taking the icon
of every taskbar pin made from the shortcut with it. Without
one, the shortcut shows the exe's own icon resource. -->
<Shortcut Id="StartMenuShortcut" <Shortcut Id="StartMenuShortcut"
Directory="ProgramMenuFolder" Directory="ProgramMenuFolder"
Name="Proton Stream" Name="Proton Stream"
Description="Browse and stream a Proton Drive public link" Description="Browse and stream a Proton Drive public link"
Target="[INSTALLDIR]proton-stream.exe" Target="[INSTALLDIR]proton-stream.exe"
WorkingDirectory="INSTALLDIR" WorkingDirectory="INSTALLDIR" />
Icon="proton-stream.ico" />
</Component> </Component>
<!-- The CLI, and the PATH entry that makes it worth installing. Tied <!-- The CLI, and the PATH entry that makes it worth installing. Tied
@@ -135,6 +149,13 @@
<File Id="MpvDll" Source="$(var.MpvDll)" Name="libmpv-2.dll" KeyPath="yes" /> <File Id="MpvDll" Source="$(var.MpvDll)" Name="libmpv-2.dll" KeyPath="yes" />
</Component> </Component>
<!-- Loads WebView2 for the CAPTCHA window Proton sometimes puts
in front of a sign-in. A -gnu build links it as a DLL; the
WebView2 runtime itself comes with Windows. -->
<Component Id="WebViewLoader" Guid="3e8a89cf-cf3e-4cc7-b1c6-9d272b3210c3" Win64="yes">
<File Id="WebViewLoader" Source="$(var.WebViewLoader)" Name="WebView2Loader.dll" KeyPath="yes" />
</Component>
<Component Id="ReadMe" Guid="5bb65b44-f5df-423b-8dcc-a753d7aef565" Win64="yes"> <Component Id="ReadMe" Guid="5bb65b44-f5df-423b-8dcc-a753d7aef565" Win64="yes">
<File Id="ReadMe" Source="$(var.ReadMe)" Name="README.md" KeyPath="yes" /> <File Id="ReadMe" Source="$(var.ReadMe)" Name="README.md" KeyPath="yes" />
</Component> </Component>
@@ -148,6 +169,7 @@
<ComponentRef Id="AppExe" /> <ComponentRef Id="AppExe" />
<ComponentRef Id="CliExe" /> <ComponentRef Id="CliExe" />
<ComponentRef Id="MpvDll" /> <ComponentRef Id="MpvDll" />
<ComponentRef Id="WebViewLoader" />
<ComponentRef Id="ReadMe" /> <ComponentRef Id="ReadMe" />
</Feature> </Feature>
+15 -2
View File
@@ -26,6 +26,15 @@
DowngradeErrorMessage="A newer version of Proton Stream is already installed." /> DowngradeErrorMessage="A newer version of Proton Stream is already installed." />
<MediaTemplate EmbedCab="yes" /> <MediaTemplate EmbedCab="yes" />
<!-- Copy every file on an upgrade, whatever is on disk. Costing runs
before RemoveExistingProducts, so it sees the old libmpv-2.dll — the
same version, since mpv is pinned — and decides not to copy it. The
removal of the old product then deletes it, and the upgrade ends with
no DLL: "mpv missing" until a second install, which finds nothing on
disk and copies it. Every file here is private to INSTALLFOLDER, so
overwriting unconditionally downgrades nothing shared. -->
<Property Id="REINSTALLMODE" Value="amus" />
<Icon Id="AppIcon" SourceFile="$(var.IconFile)" /> <Icon Id="AppIcon" SourceFile="$(var.IconFile)" />
<Property Id="ARPPRODUCTICON" Value="AppIcon" /> <Property Id="ARPPRODUCTICON" Value="AppIcon" />
<Property Id="ARPURLINFOABOUT" Value="https://github.com/narrrl/proton-stream" /> <Property Id="ARPURLINFOABOUT" Value="https://github.com/narrrl/proton-stream" />
@@ -45,12 +54,16 @@
<ComponentGroup Id="AppComponents" Directory="INSTALLFOLDER"> <ComponentGroup Id="AppComponents" Directory="INSTALLFOLDER">
<Component Guid="623c8cb8-b826-4d57-a054-e5be56f24c19"> <Component Guid="623c8cb8-b826-4d57-a054-e5be56f24c19">
<File Id="AppExe" Source="$(var.BinDir)\proton-stream.exe" KeyPath="yes" /> <File Id="AppExe" Source="$(var.BinDir)\proton-stream.exe" KeyPath="yes" />
<!-- Not advertised, and no Icon: either one puts the shortcut's icon
in C:\Windows\Installer\{ProductCode}, which the next upgrade
deletes along with the icon of every taskbar pin made from the
shortcut. This way it shows the exe's own icon resource. -->
<Shortcut Id="StartMenuShortcut" <Shortcut Id="StartMenuShortcut"
Directory="ProgramMenuFolder" Directory="ProgramMenuFolder"
Name="Proton Stream" Name="Proton Stream"
Description="Browse and stream a Proton Drive public link" Description="Browse and stream a Proton Drive public link"
Icon="AppIcon" Target="[#AppExe]"
Advertise="yes" /> WorkingDirectory="INSTALLFOLDER" />
</Component> </Component>
<!-- The CLI, and the PATH entry that makes it worth installing. Tied to <!-- The CLI, and the PATH entry that makes it worth installing. Tied to
+9 -1
View File
@@ -232,9 +232,16 @@ stage() {
[[ -f "$rel/$f" ]] || die "$f missing from $rel — drop --skip-build" [[ -f "$rel/$f" ]] || die "$f missing from $rel — drop --skip-build"
done done
# The CAPTCHA window's WebView2 loader. Under -msvc it links statically;
# under -gnu, webview2-com-sys links the DLL, which it ships and copies into
# its build output, so the exe does not start without it beside it.
local loader
loader="$(ls -t "$rel"/build/webview2-com-sys-*/out/x64/WebView2Loader.dll 2>/dev/null | head -n1)"
[[ -n "$loader" ]] || die "WebView2Loader.dll missing from $rel/build — drop --skip-build"
rm -rf "$OUTDIR" rm -rf "$OUTDIR"
mkdir -p "$OUTDIR" mkdir -p "$OUTDIR"
install -m755 "$rel/$GUI_BIN" "$rel/$CLI_BIN" "$MPV_DLL" "$OUTDIR/" install -m755 "$rel/$GUI_BIN" "$rel/$CLI_BIN" "$MPV_DLL" "$loader" "$OUTDIR/"
install -m644 "$ROOT/README.md" "$OUTDIR/" install -m644 "$ROOT/README.md" "$OUTDIR/"
if [[ -n "$SIGN_CERT" ]]; then if [[ -n "$SIGN_CERT" ]]; then
@@ -295,6 +302,7 @@ make_msi() {
-D "Version=$VERSION" \ -D "Version=$VERSION" \
-D "BinDir=$OUTDIR" \ -D "BinDir=$OUTDIR" \
-D "MpvDll=$OUTDIR/libmpv-2.dll" \ -D "MpvDll=$OUTDIR/libmpv-2.dll" \
-D "WebViewLoader=$OUTDIR/WebView2Loader.dll" \
-D "IconFile=$PKG/proton-stream.ico" \ -D "IconFile=$PKG/proton-stream.ico" \
-D "ReadMe=$OUTDIR/README.md" \ -D "ReadMe=$OUTDIR/README.md" \
-o "$out" "$PKG/proton-stream.wixl.wxs" -o "$out" "$PKG/proton-stream.wixl.wxs"
+1 -1
View File
@@ -229,7 +229,7 @@ Priority: optional
Architecture: $arch Architecture: $arch
Maintainer: Nils Pukropp <contact@narl.io> Maintainer: Nils Pukropp <contact@narl.io>
Installed-Size: $size Installed-Size: $size
Depends: libc6, libmpv2 | libmpv1, libsecret-1-0, libgl1, libxkbcommon0, libx11-6, libwayland-client0 Depends: libc6, libmpv2 | libmpv1, libsecret-1-0, libwebkit2gtk-4.1-0, libgl1, libxkbcommon0, libx11-6, libwayland-client0
Recommends: gnome-keyring Recommends: gnome-keyring
Homepage: https://github.com/narrrl/proton-stream Homepage: https://github.com/narrrl/proton-stream
Description: Netflix-style desktop client for Proton Drive public links Description: Netflix-style desktop client for Proton Drive public links