fix(player): mpv draws all Linux video, and no longer runs text from a URL

Security (DR-298, DR-299):
- The pinned libmpv crate's Mpv::command joins its arguments and calls
  mpv_command_string, which parses `;` as a command separator. Stream
  URLs carry server-controlled ids and TranscodingUrl, and a download's
  file:// path carries its track title, so a crafted title could run any
  mpv command, `run` included. Every call now goes through
  mpv_command::command, an argv built for mpv_command. The same parse
  broke loadfile for every downloaded title containing a space.
- mpv's tls-verify defaults to no, and its URLs carry the ApiKey. Every
  handle is now hardened with tls-verify=yes and ytdl=no before its
  first loadfile, and fails construction if it cannot be.

Linux video (DR-235 phase 1):
- native_video::enabled() is unconditional on Linux; the
  JELLYTAU_NATIVE_VIDEO opt-in is retired. No platform reports a
  webview video fallback, so the Settings switch no longer appears.
  Windows keeps the webview element until mpv reaches it (DR-237).
- The Linux device profile is unchanged (still h264, DR-234), so this
  ships the configuration that was tested under the env var.
This commit is contained in:
2026-09-24 20:38:32 -04:00
parent fd1277746d
commit 9d9d81bef3
13 changed files with 298 additions and 161 deletions
+37 -53
View File
@@ -2187,32 +2187,16 @@ pub struct PlaybackCapabilities {
/// native backend. Native audio exists on Linux (mpv) and Android
/// (ExoPlayer); everything else (Windows, future desktops) uses the webview.
pub uses_webview_audio: bool,
/// True when video can be rendered by a native surface composited *behind*
/// a transparent webview. Android only: ExoPlayer draws into a SurfaceView
/// beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
/// compositing), so it stays on the HTML5 element.
/// True when video is rendered by a native surface composited *behind* a
/// transparent webview: ExoPlayer's SurfaceView on Android, mpv's GL area on
/// Linux.
pub supports_native_video: bool,
/// True when the user may send video to the webview element instead of the
/// native renderer — the frontend offers the switch only then, and honours
/// the stored preference only then. See [`webview_video_fallback`].
/// the stored preference only then. False on every platform since DR-235.
pub webview_video_fallback: bool,
}
/// Whether the user may send video to the webview `<video>` element instead of
/// the native renderer.
///
/// Never on Android: ExoPlayer is its only video renderer. Downloads there are
/// the untouched source file (DR-293), and the webview decodes none of the
/// AC-3/E-AC-3/DTS/TrueHD that ExoPlayer plays through the FFmpeg extension, so
/// the fallback would be a silent film. Beside mpv's native video on Linux the
/// webview is still the tested fallback; everywhere else it is the only
/// renderer and there is nothing to switch.
///
/// TRACES: UR-003, UR-071 | DR-293 | UT-259
pub fn webview_video_fallback(is_android: bool, native_video_enabled: bool) -> bool {
!is_android && native_video_enabled
}
/// Report this platform's playback capabilities to the frontend.
///
/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
@@ -2227,11 +2211,12 @@ pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
// TRACES: UR-080 | DR-235
supports_native_video: cfg!(target_os = "android")
|| crate::player::native_video::enabled(),
// TRACES: UR-003, UR-071 | DR-293
webview_video_fallback: webview_video_fallback(
cfg!(target_os = "android"),
crate::player::native_video::enabled(),
),
// No platform offers one: Android since DR-293, Linux since DR-235,
// and on Windows the webview is the only video renderer, so there is
// nothing to fall back *from*. Kept on the wire until phase 3 deletes
// the frontend switch with the rest of the webview video path.
// TRACES: UR-080, UR-003 | DR-235, DR-293
webview_video_fallback: false,
})
}
@@ -2246,7 +2231,8 @@ pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
// two fight over the audio. TRACES: UR-080 | DR-235
(VideoBackend::Native, false)
} else {
// Linux and other platforms use HTML5 video element in frontend
// Windows: the webview <video> element is its only video renderer
// until mpv reaches it (DR-237).
(VideoBackend::Html5, true)
};
@@ -3087,35 +3073,33 @@ pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> R
mod tests {
use crate::utils::lock::MutexSafe;
/// Android has one video renderer, ExoPlayer. The webview element could only
/// be reached by the user switching native video off, and a file downloaded
/// as the untouched original — AC-3 audio included — plays silent there,
/// so the switch is gone on Android (DR-293). Where mpv draws video on Linux
/// the webview is still the tested fallback, so the switch stays there;
/// everywhere else the webview is the only renderer and there is nothing to
/// switch.
/// The webview is not a video renderer anywhere the app ships a native one:
/// Android since DR-293, Linux since DR-235 made mpv its only video path.
/// So the frontend is never offered the switch, and a stored "native video
/// off" from before cannot send Linux video back to the `<video>` element.
///
/// TRACES: UR-003, UR-071 | DR-293 | UT-259
#[test]
fn test_webview_video_fallback_is_offered_only_beside_mpv_native_video() {
use super::webview_video_fallback;
/// TRACES: UR-080, UR-003 | DR-235, DR-293 | UT-272
#[tokio::test]
async fn test_no_platform_offers_a_webview_video_fallback() {
let caps = super::player_get_capabilities().await.unwrap();
assert!(!caps.webview_video_fallback);
if cfg!(target_os = "linux") {
assert!(caps.supports_native_video, "mpv draws video on Linux");
assert!(!caps.uses_webview_audio);
}
}
assert!(
!webview_video_fallback(true, false),
"Android: ExoPlayer is the only video renderer"
);
assert!(
!webview_video_fallback(true, true),
"Android never falls back, whatever else is switched on"
);
assert!(
webview_video_fallback(false, true),
"Linux with mpv native video: the webview is the fallback"
);
assert!(
!webview_video_fallback(false, false),
"the webview is the only renderer; nothing to fall back from"
);
/// And the status the video page reads agrees: on Linux the frontend is told
/// the native backend renders, never to load a `<video>` element.
///
/// TRACES: UR-080 | DR-235 | UT-272
#[test]
fn test_linux_video_is_not_sent_to_the_webview() {
let controller = crate::player::PlayerController::default();
let status = super::get_player_status(&controller);
if cfg!(target_os = "linux") {
assert!(!status.use_html5_element);
}
}
/// UT-206 — the volume the command hands on is always a real number in
+9 -29
View File
@@ -1400,40 +1400,20 @@ pub fn run() {
// during its construction, and doing this in the order the code
// used to read produced "no mpv handle" every time — the surface was
// built before there was anything to draw from.
// Native video surface: put a GL area under Tauri's webview so mpv
// can draw beneath the controls (UR-080 / DR-231).
// Native video surface: mpv draws into the main window's own vbox,
// underneath Tauri's webview, so the Svelte controls composite over
// the picture (UR-080 / DR-231). The widget tree is left exactly as
// Tauri built it — wrapping the webview in a GtkOverlay aborts the
// process on the first click; `video_surface` explains why.
//
// 🔴 OFF BY DEFAULT — the naive reparent crashes the app on the
// first click. `tauri-runtime-wry`'s undecorated-resizing handler
// walks a hard-coded two-hop path on every button press in the
// webview:
// Unconditional on Linux since DR-235: mpv is the only Linux video
// renderer, so there is no webview path to fall back to if this
// fails — the warnings below are the whole diagnosis.
//
// webview.parent() // "This one should be GtkBox"
// .parent() // ...and this one the GtkWindow
// .downcast::<gtk::Window>().unwrap()
//
// Wrapping the webview in a GtkOverlay makes that chain
// webview → GtkOverlay → GtkBox, the downcast fails, and because the
// panic is non-unwinding it aborts the process. The decoration check
// that would otherwise make this handler inert runs *after* the
// unwrap, so no window configuration avoids it.
//
// This is the "only place Tauri-specific behaviour could still bite"
// that the spike named as the untested half of G1. It bites. The
// surface attaches perfectly and then dies on interaction, so
// "attached successfully" in the log is not the gate — a click is.
//
// Kept behind an env var rather than deleted so the next attempt has
// something to iterate on: JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev
//
// TRACES: UR-080 | DR-231
// TRACES: UR-080 | DR-231, DR-235
#[cfg(target_os = "linux")]
if crate::player::native_video::enabled() {
use tauri::Manager;
log::warn!(
"[INIT] JELLYTAU_NATIVE_VIDEO=1 — attaching the experimental \
video surface (mpv drawn behind the webview, no reparenting)"
);
if let Some(window) = app.get_webview_window("main") {
match window.default_vbox() {
Ok(vbox) => {
+2
View File
@@ -16,6 +16,8 @@ pub mod legacy_player;
pub mod media;
pub mod media_player;
#[cfg(target_os = "linux")]
pub mod mpv_command;
#[cfg(target_os = "linux")]
pub mod mpv_player;
pub mod queue;
pub mod seek;
+17 -13
View File
@@ -148,6 +148,8 @@ impl MpvBackend {
let mpv = Mpv::new().map_err(|e| PlayerError {
message: format!("Failed to initialize MPV: {:?}", e),
})?;
// TRACES: UR-012 | DR-299
super::mpv_command::harden(&mpv).map_err(|message| PlayerError { message })?;
// Detect and configure audio output
let audio_driver = detect_audio_system();
@@ -178,11 +180,11 @@ impl MpvBackend {
// Video is disabled unless this process is drawing it.
//
// `video: no` is why mpv has never decoded a frame here: Linux video has
// always gone through the webview, and decoding it twice would burn a
// core for a picture nobody sees. With native video on, mpv needs both
// the decoder *and* `vo=libmpv` — the render API only works through that
// output, and the default would try to open a window of its own.
// Linux video went through the webview until DR-235, and decoding it
// here too would have burned a core for a picture nobody saw — hence
// `video: no`. With native video, mpv needs both the decoder *and*
// `vo=libmpv` — the render API only works through that output, and the
// default would try to open a window of its own.
//
// Set at construction because mpv resolves the video output when it
// initialises; flipping it later does not re-open one.
@@ -600,12 +602,14 @@ impl PlayerBackend for MpvBackend {
// TRACES: UR-040, UR-005 | DR-253
*self.pending_seek.lock_safe() = None;
// Load the media file
self.mpv
.command("loadfile", &[&stream_url])
.map_err(|e| PlayerError {
message: format!("Failed to load file: {:?}", e),
})?;
// Load the media file. Through `mpv_command::command`, never
// `Mpv::command`: the URL carries server-controlled text.
// TRACES: UR-003, UR-004 | DR-298
super::mpv_command::command(&self.mpv, &["loadfile", &stream_url]).map_err(|e| {
PlayerError {
message: format!("Failed to load file: {e}"),
}
})?;
debug!("[MpvBackend] Load command sent successfully");
Ok(())
@@ -638,8 +642,8 @@ impl PlayerBackend for MpvBackend {
fn stop(&mut self) -> Result<(), PlayerError> {
debug!("[MpvBackend] Stop command");
self.mpv.command("stop", &[]).map_err(|e| PlayerError {
message: format!("Failed to stop: {:?}", e),
super::mpv_command::command(&self.mpv, &["stop"]).map_err(|e| PlayerError {
message: format!("Failed to stop: {e}"),
})?;
// Stopping ends the seek's subject along with the playback.
+161
View File
@@ -0,0 +1,161 @@
//! The two things every libmpv handle in this process must get right before it
//! is handed a URL.
//!
//! **Commands are an argument vector.** The pinned `libmpv` crate's
//! `Mpv::command` joins its arguments with spaces and hands the result to
//! `mpv_command_string`, which parses it as input.conf syntax: whitespace splits
//! arguments, `;` separates commands, `#` starts a comment. Every URL this app
//! loads carries server-controlled text — item and media-source ids, the
//! server's own `TranscodingUrl`, and for a download the file name, which is the
//! track title — so a title like `x;run sh -c …;#` ran a shell command the
//! moment it played. [`command`] goes through `mpv_command` instead, where each
//! argument reaches mpv as one opaque string and nothing is parsed.
//!
//! **TLS is verified.** mpv's `tls-verify` defaults to *no*, and the stream URLs
//! it loads carry the account's `ApiKey`. Every reqwest client in the app
//! verifies certificates; without [`harden`] mpv was the one path where anyone
//! able to present a certificate for the server's host could read the token.
//! `ytdl` goes with it: libmpv loads its youtube-dl hook by default and hands a
//! URL that failed to open — token included — to an external `yt-dlp`.
//!
//! TRACES: UR-003, UR-004, UR-012 | DR-298, DR-299
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use libmpv::Mpv;
/// Run an mpv command with each argument passed through verbatim.
///
/// TRACES: UR-003, UR-004 | DR-298 | UT-268
pub fn command(mpv: &Mpv, args: &[&str]) -> Result<(), String> {
if args.is_empty() {
return Err("empty mpv command".to_string());
}
// A NUL cannot be represented in a C string; refusing is the only honest
// answer, since truncating would load a different URL than the one asked.
let owned = args
.iter()
.map(|a| CString::new(*a).map_err(|_| format!("mpv argument contains NUL: {a:?}")))
.collect::<Result<Vec<_>, _>>()?;
let mut argv: Vec<*const c_char> = owned.iter().map(|a| a.as_ptr()).collect();
argv.push(std::ptr::null());
// SAFETY: `argv` is a NULL-terminated array of pointers into `owned`, which
// outlives the call; mpv copies what it keeps. `ctx` is the live handle.
let rc = unsafe { libmpv_sys::mpv_command(mpv.ctx.as_ptr(), argv.as_mut_ptr()) };
if rc < 0 {
// SAFETY: mpv_error_string returns a static string for any code.
let msg = unsafe { CStr::from_ptr(libmpv_sys::mpv_error_string(rc)) };
return Err(format!("{} ({rc})", msg.to_string_lossy()));
}
Ok(())
}
/// Configure a freshly created handle so it will not trust an unverified server.
///
/// Must run before the first `loadfile`. Failure is an error, not a warning: a
/// handle that could not be told to verify TLS is exactly the one that leaks.
///
/// TRACES: UR-012 | DR-299 | UT-270
pub fn harden(mpv: &Mpv) -> Result<(), String> {
for (name, value) in [("tls-verify", "yes"), ("ytdl", "no")] {
mpv.set_property(name, value)
.map_err(|e| format!("could not set {name}={value}: {e:?}"))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// A handle that decodes nothing and opens no device, so the tests need no
/// audio system and no display.
fn null_mpv() -> Mpv {
let mpv = Mpv::new().expect("libmpv must be available to run the player tests");
mpv.set_property("ao", "null").unwrap();
mpv.set_property("vo", "null").unwrap();
mpv
}
/// The injection itself, against a real libmpv: a URL carrying `;` and a
/// second command must be loaded as one (unreachable) URL, not split and
/// executed. `set volume 13` stands in for `run …` — the same parse, but
/// observable without spawning a process.
///
/// TRACES: UR-003, UR-004 | DR-298 | UT-268
#[test]
fn a_url_cannot_smuggle_a_second_mpv_command() {
let mpv = null_mpv();
mpv.set_property("volume", 100i64).unwrap();
// Port 9 (discard) on loopback: nothing is fetched either way.
let url = "http://127.0.0.1:9/Audio/x;set volume 13;#/stream?ApiKey=k";
let _ = command(&mpv, &["loadfile", url, "replace"]);
let volume: i64 = mpv.get_property("volume").unwrap();
assert_eq!(
volume, 100,
"text inside a URL was executed as an mpv command"
);
}
/// An argument with a space in it — every downloaded title with one —
/// arrives as one argument rather than being split into the next slot.
///
/// TRACES: UR-003, UR-004 | DR-298 | UT-268
#[test]
fn an_argument_with_spaces_stays_one_argument() {
let mpv = null_mpv();
// Split on the space this would be `loadfile file:///tmp/My Song.mp3`
// — `Song.mp3` taken as the flags argument, which mpv rejects.
assert!(command(&mpv, &["loadfile", "file:///nonexistent/My Song.mp3"]).is_ok());
}
/// No playback path may call the string-joining `Mpv::command` directly;
/// they all go through [`command`]. Asserted against the source because the
/// dangerous call and the safe one have the same shape at the call site.
///
/// TRACES: UR-003, UR-004 | DR-298 | UT-269
#[test]
fn players_never_use_the_string_command_api() {
for (file, src) in [
("mpv_backend.rs", include_str!("mpv_backend.rs")),
("mpv_player.rs", include_str!("mpv_player.rs")),
] {
assert!(
!src.contains(".command(\""),
"{file} calls Mpv::command, which parses its arguments as a command string"
);
}
}
/// TRACES: UR-012 | DR-299 | UT-270
#[test]
fn a_hardened_handle_verifies_tls_and_never_hands_urls_to_ytdl() {
let mpv = null_mpv();
harden(&mpv).unwrap();
let tls: String = mpv.get_property("tls-verify").unwrap();
assert_eq!(tls, "yes");
let ytdl: String = mpv.get_property("ytdl").unwrap();
assert_eq!(ytdl, "no");
}
/// Both constructors apply [`harden`]; a handle built without it is the bug.
///
/// TRACES: UR-012 | DR-299 | UT-270
#[test]
fn every_player_hardens_its_handle() {
for (file, src) in [
("mpv_backend.rs", include_str!("mpv_backend.rs")),
("mpv_player.rs", include_str!("mpv_player.rs")),
] {
assert!(
src.contains("mpv_command::harden(&mpv)"),
"{file} creates an mpv handle without hardening it"
);
}
}
}
+7 -5
View File
@@ -85,6 +85,8 @@ impl MpvPlayer {
let mpv = Mpv::new().map_err(|e| PlayerError {
message: format!("mpv_create failed: {e:?}"),
})?;
// TRACES: UR-012 | DR-299
super::mpv_command::harden(&mpv).map_err(|message| PlayerError { message })?;
let set = |k: &str, v: &str| {
if let Err(e) = mpv.set_property(k, v) {
@@ -229,10 +231,10 @@ impl MediaPlayer for MpvPlayer {
})?;
info!("[MpvPlayer] open {} at {:?}", req.selection.url, req.start);
self.mpv
.command("loadfile", &[&req.selection.url, "replace"])
// TRACES: UR-003, UR-004 | DR-298
super::mpv_command::command(&self.mpv, &["loadfile", &req.selection.url, "replace"])
.map_err(|e| PlayerError {
message: format!("loadfile failed: {e:?}"),
message: format!("loadfile failed: {e}"),
})?;
Ok(())
}
@@ -275,8 +277,8 @@ impl MediaPlayer for MpvPlayer {
}
// Idempotent: stopping an already-stopped mpv is not an error worth
// propagating, and callers legitimately close twice on teardown.
if let Err(e) = self.mpv.command("stop", &[]) {
debug!("[MpvPlayer] stop on an idle player: {e:?}");
if let Err(e) = super::mpv_command::command(&self.mpv, &["stop"]) {
debug!("[MpvPlayer] stop on an idle player: {e}");
}
Ok(())
}
+23 -38
View File
@@ -14,65 +14,50 @@
//!
//! TRACES: UR-080 | DR-231, DR-235
/// The opt-in for native desktop video.
///
/// Off by default while the render path is unproven — the webview path still
/// works and is what ships. This becomes the *default* (and then the only path)
/// when DR-235 lands; the variable is how it is exercised until then.
const ENV_FLAG: &str = "JELLYTAU_NATIVE_VIDEO";
/// Whether mpv should decode and draw video in this process.
///
/// Read fresh rather than cached: it is consulted a handful of times at startup,
/// and a `OnceLock` here would only make it harder to test.
/// True wherever mpv is the desktop video renderer — Linux, since DR-235
/// phase 1. There is no opt-out: the webview `<video>` path is no longer a Linux
/// video renderer, so "off" would leave nothing drawing the picture. It was the
/// `JELLYTAU_NATIVE_VIDEO` opt-in while the render path was being proven; the
/// variable is now ignored. Windows joins in DR-237, and only then does the
/// webview path go (phase 3).
///
/// TRACES: UR-080 | DR-231, DR-235
pub fn enabled() -> bool {
// Only where a native renderer exists. On Android ExoPlayer already does
// this and `use_html5_element` is false for entirely separate reasons.
if !cfg!(all(target_os = "linux", not(target_os = "android"))) {
return false;
}
matches!(
std::env::var(ENV_FLAG).as_deref(),
Ok("1") | Ok("true") | Ok("yes")
)
// On Android ExoPlayer draws video and `use_html5_element` is false for
// entirely separate reasons.
cfg!(all(target_os = "linux", not(target_os = "android")))
}
#[cfg(test)]
mod tests {
use super::*;
/// Absent, empty, or anything unrecognised means off. A half-set variable
/// must not half-enable a renderer — the failure mode would be mpv
/// configured for video with nothing drawing it, i.e. audio playing over a
/// black rectangle.
/// mpv draws video on Linux with nothing to opt into, and the retired
/// variable cannot opt back out: with the webview path gone from Linux, "off"
/// would configure mpv for audio only with nothing else to draw the picture.
///
/// TRACES: UR-080 | DR-231 | UT-216
/// TRACES: UR-080 | DR-235 | UT-271
#[test]
fn test_only_explicit_truthy_values_enable_it() {
let restore = std::env::var(ENV_FLAG).ok();
fn linux_always_renders_video_natively() {
let restore = std::env::var("JELLYTAU_NATIVE_VIDEO").ok();
for value in ["", "0", "no", "false", "maybe", "2"] {
std::env::set_var(ENV_FLAG, value);
assert!(!enabled(), "{value:?} must not enable native video");
}
for value in ["1", "true", "yes"] {
std::env::set_var(ENV_FLAG, value);
for value in [None, Some("0"), Some("false"), Some("1")] {
match value {
Some(v) => std::env::set_var("JELLYTAU_NATIVE_VIDEO", v),
None => std::env::remove_var("JELLYTAU_NATIVE_VIDEO"),
}
assert_eq!(
enabled(),
cfg!(all(target_os = "linux", not(target_os = "android"))),
"{value:?} enables it exactly where a native renderer exists"
"JELLYTAU_NATIVE_VIDEO={value:?} must not decide the renderer"
);
}
std::env::remove_var(ENV_FLAG);
assert!(!enabled(), "absent means off");
match restore {
Some(v) => std::env::set_var(ENV_FLAG, v),
None => std::env::remove_var(ENV_FLAG),
Some(v) => std::env::set_var("JELLYTAU_NATIVE_VIDEO", v),
None => std::env::remove_var("JELLYTAU_NATIVE_VIDEO"),
}
}
}