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:
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user