- 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.
8.3 KiB
Architecture
The problem
A Proton Drive public link is normally a download link. This app turns one into a streamable library.
Nothing about the crypto prevents that: a file's content blocks are each a
self-contained PGP SEIPD packet decrypted under one shared session key, so there
is no sequential streaming session to resume and seeking costs exactly the blocks
the seek lands on. What was missing was plumbing — RevisionReader, the SDK's
random-access reader, was welded to the authenticated client. Undoing that weld
(proton-sdk-rs 0.3.3, transport.rs) is what made this app possible.
Crates
pstr-app (bin: proton-stream) pstr-cli (bin: pstr)
│ │
├── pstr-player ── libmpv │
│ │ stream_cb + render API │
│ ▼ │
└── pstr-stream ───────────────────┤
│ │
└── pstr-core ─────────────┘
│
proton-drive-rs ──▶ proton-sdk
pstr-core— the share list and its secrets, the catalog database, the filename parser, and the crawl. Knows nothing about playback or pixels.pstr-stream—RevisionReaderto a seekable byte stream: reader LRU, in-memory block ring, forward read-ahead, optional on-disk block cache. Only itsreadermodule touches the SDK; everything else runs against aBlockSourcetrait, which is what makes the caching and read-ahead testable with no account and no network.pstr-player— libmpv. Fed through a custompstr://protocol registered withmpv_stream_cb_add_ro; rendered throughmpv_render_context_createwithMPV_RENDER_API_TYPE_OPENGLinto eframe's glow context. The URL is an opaque token into a registry, never an identity: the caller opens the stream in async code where a failure can be reported, then publishes it. mpv's open callback is a hash lookup that cannot block and cannot leak a share secret into a log line. Everything the callbacks decide lives in a safe, testedStreamCursor; theunsafeis confined to one module of pointer plumbing. What is inside the file — audio and subtitle tracks — is read fromtrack-listone sub-property at a time (tracks.rs) and selected throughaid/sid; the language of a choice is carried to the next file withalang/slang, which is what makes "Japanese audio" hold for the rest of a season.pstr-app— the egui UI. Three threads' worth of work kept apart: the UI thread only draws, anEngineruns everything with a network or a disk in it on the tokio runtime and answers with events, and a player thread owns mpv for the length of a file. Pages are pure functions of state that pushActions rather than mutating, which is what allows a click to change the page it was drawn on.pstr-cli— the headless front end, and the thing that tells you which layer is broken.
Data flow, playing an episode
click egui
│
▼
pstr://<share>/<link_id> mpv opens the URL, calls back on its demuxer thread
│
▼
VideoStream::read_at pstr-stream: ring? disk cache? else fetch
│
▼
RevisionReader::read_at SDK: the blocks overlapping the range, 10 at a time
│
▼
get_storage_blob block storage, pm-storage-token, no session credential
│
▼
ContentKey::decrypt_block one PGP packet per block, on the blocking pool
The demuxer thread is allowed to block, so the stream_cb callbacks bridge into
a dedicated tokio runtime with Handle::block_on.
Two properties of that middle layer are load-bearing enough to state here, both
established by measurement rather than reasoning (docs/TESTING.md):
- A seek cancels outstanding read-ahead before it fetches anything. Otherwise the prefetches from the abandoned position hold the bandwidth the seek needs — worth 5x on worst-case seek latency.
- Read-ahead never runs deeper than half the ring. A window larger than the cache it lands in evicts its own blocks before the player reaches them.
- How far to read ahead belongs to the consumer, not to the block layer. mpv buffers ahead of the picture itself, so under mpv the block layer reads ahead not at all; a second speculative layer only takes bandwidth from the reads mpv is blocked on. A reader with no buffer of its own still wants twelve.
Every block is fetched exactly once no matter how many readers want it: the open, and each block fetch, go through a single-flight keyed on the block. The demand read and the prefetch racing for the same block is the normal case, not an edge case.
Trust and secrets
- The share URL fragment is the decryption password, and a custom-password
link has a second secret. Both live in the OS credential store;
shares.jsonholds only the id, display name and token. - Nothing this app holds can write to the share: a viewer link grants
MemberRole::Viewer, and the SDK's public-link write side is unported anyway. - Signing in to a Proton account is optional (
pstr-core::account). The session — tokens, the mailbox password that unlocks the key chain, and the key salts a refreshed token can no longer fetch — is one blob in the same credential store, underproton-account. 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 — soKeyringSecretStoresplits a long value acrosskey#0,key#1, … under a header inkey. The account writes exactly one kind of thing: the watch-history files on its ownproton-streamdevice (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 tohttps://*.proton.me. - Signature verification is absent on the visitor path by design — an anonymous
visitor cannot read
core/v4/keys/allto resolve a signer. Decryption still proves the bytes were encrypted to the node key. Nodes come back with nothing claimed rather than a verification that silently passed. - Metadata enrichment is off by default because enabling it sends the user's library titles to a third party.
Persistence
| Store | Path | Rebuildable? |
|---|---|---|
| Share list | config dir, shares.json |
No — atomic writes, never overwritten when unparseable |
| Share secrets | OS credential store | No |
| Catalog | data dir, catalog.db |
Yes — recrawl |
| Watch state | same DB, own table | No — deliberately survives a recrawl 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 |
| 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 |
| Desktop-only settings | config dir, desktop.json |
Defaults if missing — hardware decoding, cache budget; never overwritten when unparseable |
| Window size | config dir, window.json |
Defaults if missing or implausible |
| Single-instance pipe key (Windows) | config dir, instance.key |
Yes — regenerated if missing |
| Notification app id (Windows) | HKCU\Software\Classes\AppUserModelId\io.narl.proton-stream, icon in cache dir |
Yes — written again the first time a session notifies |