mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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.
150 lines
8.4 KiB
Markdown
150 lines
8.4 KiB
Markdown
# 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`** — `RevisionReader` to a seekable byte stream: reader LRU,
|
|
in-memory block ring, forward read-ahead, optional on-disk block cache. Only
|
|
its `reader` module touches the SDK; everything else runs against a
|
|
`BlockSource` trait, which is what makes the caching and read-ahead testable
|
|
with no account and no network.
|
|
- **`pstr-player`** — libmpv. Fed through a custom `pstr://` protocol registered
|
|
with `mpv_stream_cb_add_ro`; rendered through `mpv_render_context_create` with
|
|
`MPV_RENDER_API_TYPE_OPENGL` into 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, tested `StreamCursor`; the
|
|
`unsafe` is confined to one module of pointer plumbing. What is inside the file
|
|
— audio and subtitle tracks — is read from `track-list` one sub-property at a
|
|
time (`tracks.rs`) and selected through `aid`/`sid`; the *language* of a choice
|
|
is carried to the next file with `alang`/`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, an `Engine` runs 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 push `Action`s
|
|
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.json`
|
|
holds only the id, display name and token.
|
|
- Nothing this app holds can write to the share: a viewer link grants
|
|
`MemberRole::Viewer`, and the SDK's public-link write side is unported anyway.
|
|
- Signing in to a Proton account is optional (`pstr-core::account`). The
|
|
session — tokens, 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
|
|
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
|
|
*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 |
|