cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

refactor(source): extract capture preflight

Dami committed a42e112
commit
a42e112045022c56cf890328d72236a753cc9b19
parent
61922d88d480137198353d07d3f3b1c37a501aca
author
Dami <dami.etoile@gmail.com>
2 files changed, +289 -270
Mcrates/sigil-host/src/source.rs274+----------------------------
Acrates/sigil-host/src/source/preflight.rs285++++++++++++++++++++++++++++++
M crates/sigil-host/src/source.rs +4 -270
@@ -1,6 +1,5 @@
11 use std::borrow::Cow;22 use std::ffi::OsString;3-use std::fs;43 use std::process::{ExitStatus, Stdio};54 use std::sync::Arc;65 use std::sync::atomic::{AtomicU64, Ordering};
@@ -20,19 +19,18 @@ use crate::config::{
2019 };2120 2221 mod annexb;22+mod preflight;2323 2424 use annexb::AnnexBAccessUnitParser;25+use preflight::preflight_gamescope_static;26+pub(crate) use preflight::{preflight_gstreamer_element, validate_executable};2527 2628 #[cfg(all(target_os = "linux", feature = "in-process-gstreamer"))]2729 use gstreamer::prelude::ObjectExt;2830 29-#[cfg(unix)]30-use std::os::unix::fs::{FileTypeExt, PermissionsExt};31-3231 const MAX_ENCODE_BUFFER: usize = 16 * 1024 * 1024;3332 const MAX_CURRENT_GOP_BYTES: usize = 32 * 1024 * 1024;3433 const MAX_PW_DUMP_OUTPUT: usize = 8 * 1024 * 1024;35-const MAX_INSPECT_OUTPUT: usize = 1024 * 1024;3634 const MAX_DIAGNOSTIC_OUTPUT: usize = 512 * 1024;3735 const PREFLIGHT_TIMEOUT: Duration = Duration::from_secs(5);3836 const MAX_ENCODER_BITRATE_KBPS: u32 = 100_000;
@@ -454,51 +452,6 @@ pub async fn preflight_gamescope_pipewire(config: &HostConfig) -> Result<Gamesco
454452 resolve_gamescope_pipewire_target(config).await455453 }456454 457-async fn preflight_gamescope_static(config: &HostConfig) -> Result<()> {458- let pipewire = gamescope_config(config)?;459-460- for (name, path) in [461- ("pw_dump_path", &pipewire.pw_dump_path),462- ("gst_inspect_path", &pipewire.gst_inspect_path),463- ] {464- validate_executable(name, path)?;465- }466- if pipewire.encoder_backend == GamescopeEncoderBackend::ExternalGstLaunch {467- validate_executable("gst_launch_path", &pipewire.gst_launch_path)?;468- }469- validate_amd_render_node(&pipewire.vaapi_render_node)?;470-471- let terminal_sink = match pipewire.encoder_backend {472- GamescopeEncoderBackend::ExternalGstLaunch => "fdsink",473- GamescopeEncoderBackend::InProcessGstreamer => "appsink",474- };475- for element in [476- "pipewiresrc",477- "queue",478- "videoconvert",479- "videoscale",480- "videorate",481- "capsfilter",482- pipewire.vaapi_encoder.as_str(),483- "h264parse",484- terminal_sink,485- ] {486- let mut command = Command::new(&pipewire.gst_inspect_path);487- command.arg(element).env("LC_ALL", "C");488- let output = run_bounded_command(command, MAX_INSPECT_OUTPUT).await?;489- ensure!(490- output.status.success(),491- "GStreamer element {element:?} is unavailable: {}",492- diagnostic(&output.stderr)493- );494- if element == pipewire.vaapi_encoder {495- validate_encoder_inspection(&output.stdout, pipewire)?;496- }497- }498-499- Ok(())500-}501-502455 async fn resolve_gamescope_pipewire_target(config: &HostConfig) -> Result<GamescopePreflight> {503456 config.validate()?;504457 let pipewire = gamescope_config(config)?;
@@ -1778,173 +1731,6 @@ fn property_string(value: Option<&Value>) -> Option<Cow<'_, str>> {
17781731 }17791732 }17801733 1781-fn validate_encoder_inspection(output: &[u8], config: &GamescopePipewireConfig) -> Result<()> {1782- let output = std::str::from_utf8(output).context("gst-inspect output is not UTF-8")?;1783- let lines: Vec<&str> = output.lines().collect();1784- let device_block = gst_property_block(&lines, "device-path")1785- .context("configured VA encoder does not report a device-path property")?;1786- let expected = config.vaapi_render_node.to_string_lossy();1787- let expected_default = format!("Default: \"{expected}\"");1788- ensure!(1789- device_block.contains(&expected_default),1790- "configured VA encoder {:?} does not report expected device-path {}",1791- config.vaapi_encoder,1792- config.vaapi_render_node.display()1793- );1794- for property in [1795- "aud",1796- "b-frames",1797- "key-int-max",1798- "rate-control",1799- "ref-frames",1800- "target-usage",1801- ] {1802- let block = gst_property_block(&lines, property).with_context(|| {1803- format!(1804- "configured VA encoder {:?} has no {property} property",1805- config.vaapi_encoder1806- )1807- })?;1808- ensure!(1809- block.contains("writable") || block.contains("Writable"),1810- "configured VA encoder {:?} property {property} is not writable",1811- config.vaapi_encoder1812- );1813- }1814- let rate_control =1815- gst_property_block(&lines, "rate-control").expect("rate-control block was checked above");1816- let expected_rate_control = match config.rate_control {1817- VaapiRateControl::Cbr => "cbr",1818- VaapiRateControl::Cqp => "cqp",1819- };1820- ensure!(1821- rate_control.contains(expected_rate_control),1822- "configured VA encoder {:?} does not advertise {expected_rate_control} rate control",1823- config.vaapi_encoder1824- );1825- let mode_properties: &[&str] = match config.rate_control {1826- VaapiRateControl::Cbr => &["bitrate"],1827- VaapiRateControl::Cqp => &["qpi", "qpp"],1828- };1829- for property in mode_properties {1830- let block = gst_property_block(&lines, property).with_context(|| {1831- format!(1832- "configured VA encoder {:?} has no {property} property",1833- config.vaapi_encoder1834- )1835- })?;1836- ensure!(1837- block.contains("writable") || block.contains("Writable"),1838- "configured VA encoder {:?} property {property} is not writable",1839- config.vaapi_encoder1840- );1841- }1842- Ok(())1843-}1844-1845-fn gst_property_block(lines: &[&str], property: &str) -> Option<String> {1846- let start = lines.iter().position(|line| {1847- line.strip_prefix(" ").is_some_and(|line| {1848- line.starts_with(property) && line[property.len()..].starts_with(' ')1849- })1850- })?;1851- let end = lines[start + 1..]1852- .iter()1853- .position(|line| {1854- line.strip_prefix(" ")1855- .is_some_and(|line| !line.starts_with(' ') && line.split_once(" : ").is_some())1856- })1857- .map_or(lines.len(), |offset| start + 1 + offset);1858- Some(lines[start..end].join("\n"))1859-}1860-1861-pub(crate) fn validate_executable(name: &str, path: &std::path::Path) -> Result<()> {1862- let metadata = fs::symlink_metadata(path)1863- .with_context(|| format!("inspecting gamescope_pipewire.{name} at {}", path.display()))?;1864- ensure!(1865- !metadata.file_type().is_symlink(),1866- "gamescope_pipewire.{name} must not be a symlink"1867- );1868- ensure!(1869- metadata.is_file(),1870- "gamescope_pipewire.{name} is not a file"1871- );1872- #[cfg(unix)]1873- {1874- use std::os::unix::fs::MetadataExt;1875-1876- ensure!(1877- metadata.permissions().mode() & 0o111 != 0,1878- "gamescope_pipewire.{name} is not executable"1879- );1880- ensure!(1881- metadata.permissions().mode() & 0o022 == 0,1882- "gamescope_pipewire.{name} must not be writable by group or other users"1883- );1884- let owner = metadata.uid();1885- let effective_user = unsafe { libc::geteuid() };1886- ensure!(1887- owner == 0 || owner == effective_user,1888- "gamescope_pipewire.{name} must be owned by root or the current user"1889- );1890- }1891- Ok(())1892-}1893-1894-pub(crate) async fn preflight_gstreamer_element(1895- gst_inspect_path: &std::path::Path,1896- element: &str,1897-) -> Result<()> {1898- let mut command = Command::new(gst_inspect_path);1899- command.arg(element).env("LC_ALL", "C");1900- let output = run_bounded_command(command, MAX_INSPECT_OUTPUT).await?;1901- ensure!(1902- output.status.success(),1903- "GStreamer element {element:?} is unavailable: {}",1904- diagnostic(&output.stderr)1905- );1906- Ok(())1907-}1908-1909-fn validate_amd_render_node(path: &std::path::Path) -> Result<()> {1910- let metadata = fs::symlink_metadata(path)1911- .with_context(|| format!("inspecting VAAPI render node {}", path.display()))?;1912- #[cfg(unix)]1913- ensure!(1914- metadata.file_type().is_char_device(),1915- "VAAPI render node {} is not a character device",1916- path.display()1917- );1918- fs::OpenOptions::new()1919- .read(true)1920- .write(true)1921- .open(path)1922- .with_context(|| {1923- format!(1924- "opening VAAPI render node {} read/write as the current user",1925- path.display()1926- )1927- })?;1928- #[cfg(target_os = "linux")]1929- {1930- let name = path1931- .file_name()1932- .context("VAAPI render node has no file name")?;1933- let driver = fs::canonicalize(1934- std::path::Path::new("/sys/class/drm")1935- .join(name)1936- .join("device/driver"),1937- )1938- .with_context(|| format!("resolving kernel driver for {}", path.display()))?;1939- ensure!(1940- driver.file_name().and_then(|name| name.to_str()) == Some("amdgpu"),1941- "VAAPI render node {} is not backed by amdgpu",1942- path.display()1943- );1944- }1945- Ok(())1946-}1947-19481734 struct BoundedOutput {19491735 status: ExitStatus,19501736 stdout: Vec<u8>,
@@ -2057,7 +1843,7 @@ mod tests {
20571843 use std::collections::BTreeMap;20581844 use std::path::PathBuf;20591845 2060- fn gamescope_config() -> HostConfig {1846+ pub(super) fn gamescope_config() -> HostConfig {20611847 HostConfig {20621848 identity_path: PathBuf::from("/tmp/host.key"),20631849 state_path: PathBuf::from("/tmp/state"),
@@ -2855,56 +2641,4 @@ mod tests {
28552641 let error = resolve_pipewire_node(odd, &native_config).unwrap_err();28562642 assert!(error.to_string().contains("must be even"), "{error:#}");28572643 }2858-2859- #[test]2860- fn requires_encoder_to_report_configured_render_node() {2861- let config = gamescope_config();2862- let pipewire = config.gamescope_pipewire.as_ref().unwrap();2863- let inspect = br#"2864-Element Properties:2865- device-path : The DRM device path used for VA operation2866- flags: readable2867- String. Default: "/dev/dri/renderD128"2868- aud : Insert an AU delimiter for each frame2869- flags: readable, writable2870- b-frames : Number of B-frames2871- flags: readable, writable2872- bitrate : Target bitrate2873- flags: readable, writable2874- key-int-max : Maximum keyframe distance2875- flags: readable, writable2876- rate-control : Rate control mode2877- flags: readable, writable2878- Enum. Default: cbr2879- (2): cbr - Constant Bitrate2880- ref-frames : Number of reference frames2881- flags: readable, writable2882- target-usage : Speed and quality tradeoff2883- flags: readable, writable2884-"#;2885- validate_encoder_inspection(inspect, pipewire).unwrap();2886-2887- let index = inspect2888- .windows(b"renderD128".len())2889- .position(|window| window == b"renderD128")2890- .unwrap();2891- let mut wrong = inspect.to_vec();2892- wrong[index..index + b"renderD128".len()].copy_from_slice(b"renderD129");2893- assert!(validate_encoder_inspection(&wrong, pipewire).is_err());2894-2895- let mut cqp = gamescope_config();2896- let pipewire = cqp.gamescope_pipewire.as_mut().unwrap();2897- pipewire.vaapi_encoder = "vah264lpenc".into();2898- pipewire.rate_control = VaapiRateControl::Cqp;2899- pipewire.bitrate_kbps = None;2900- pipewire.quantizer = Some(24);2901- let cqp_inspect = String::from_utf8_lossy(inspect)2902- .replace("Default: cbr", "Default: cqp")2903- .replace("(2): cbr", "(16): cqp")2904- .replace(2905- " ref-frames",2906- " qpi : I-frame quantizer\n flags: readable, writable\n qpp : P-frame quantizer\n flags: readable, writable\n ref-frames",2907- );2908- validate_encoder_inspection(cqp_inspect.as_bytes(), pipewire).unwrap();2909- }29102644 }
A crates/sigil-host/src/source/preflight.rs +285 -0
@@ -0,0 +1,285 @@
1+use std::fs;2+3+use anyhow::{Context, Result, ensure};4+use tokio::process::Command;5+6+use crate::config::{7+ GamescopeEncoderBackend, GamescopePipewireConfig, HostConfig, VaapiRateControl,8+};9+10+use super::{diagnostic, gamescope_config, run_bounded_command};11+12+#[cfg(unix)]13+use std::os::unix::fs::{FileTypeExt, PermissionsExt};14+15+const MAX_INSPECT_OUTPUT: usize = 1024 * 1024;16+17+pub(super) async fn preflight_gamescope_static(config: &HostConfig) -> Result<()> {18+ let pipewire = gamescope_config(config)?;19+20+ for (name, path) in [21+ ("pw_dump_path", &pipewire.pw_dump_path),22+ ("gst_inspect_path", &pipewire.gst_inspect_path),23+ ] {24+ validate_executable(name, path)?;25+ }26+ if pipewire.encoder_backend == GamescopeEncoderBackend::ExternalGstLaunch {27+ validate_executable("gst_launch_path", &pipewire.gst_launch_path)?;28+ }29+ validate_amd_render_node(&pipewire.vaapi_render_node)?;30+31+ let terminal_sink = match pipewire.encoder_backend {32+ GamescopeEncoderBackend::ExternalGstLaunch => "fdsink",33+ GamescopeEncoderBackend::InProcessGstreamer => "appsink",34+ };35+ for element in [36+ "pipewiresrc",37+ "queue",38+ "videoconvert",39+ "videoscale",40+ "videorate",41+ "capsfilter",42+ pipewire.vaapi_encoder.as_str(),43+ "h264parse",44+ terminal_sink,45+ ] {46+ let mut command = Command::new(&pipewire.gst_inspect_path);47+ command.arg(element).env("LC_ALL", "C");48+ let output = run_bounded_command(command, MAX_INSPECT_OUTPUT).await?;49+ ensure!(50+ output.status.success(),51+ "GStreamer element {element:?} is unavailable: {}",52+ diagnostic(&output.stderr)53+ );54+ if element == pipewire.vaapi_encoder {55+ validate_encoder_inspection(&output.stdout, pipewire)?;56+ }57+ }58+59+ Ok(())60+}61+62+fn validate_encoder_inspection(output: &[u8], config: &GamescopePipewireConfig) -> Result<()> {63+ let output = std::str::from_utf8(output).context("gst-inspect output is not UTF-8")?;64+ let lines: Vec<&str> = output.lines().collect();65+ let device_block = gst_property_block(&lines, "device-path")66+ .context("configured VA encoder does not report a device-path property")?;67+ let expected = config.vaapi_render_node.to_string_lossy();68+ let expected_default = format!("Default: \"{expected}\"");69+ ensure!(70+ device_block.contains(&expected_default),71+ "configured VA encoder {:?} does not report expected device-path {}",72+ config.vaapi_encoder,73+ config.vaapi_render_node.display()74+ );75+ for property in [76+ "aud",77+ "b-frames",78+ "key-int-max",79+ "rate-control",80+ "ref-frames",81+ "target-usage",82+ ] {83+ let block = gst_property_block(&lines, property).with_context(|| {84+ format!(85+ "configured VA encoder {:?} has no {property} property",86+ config.vaapi_encoder87+ )88+ })?;89+ ensure!(90+ block.contains("writable") || block.contains("Writable"),91+ "configured VA encoder {:?} property {property} is not writable",92+ config.vaapi_encoder93+ );94+ }95+ let rate_control =96+ gst_property_block(&lines, "rate-control").expect("rate-control block was checked above");97+ let expected_rate_control = match config.rate_control {98+ VaapiRateControl::Cbr => "cbr",99+ VaapiRateControl::Cqp => "cqp",100+ };101+ ensure!(102+ rate_control.contains(expected_rate_control),103+ "configured VA encoder {:?} does not advertise {expected_rate_control} rate control",104+ config.vaapi_encoder105+ );106+ let mode_properties: &[&str] = match config.rate_control {107+ VaapiRateControl::Cbr => &["bitrate"],108+ VaapiRateControl::Cqp => &["qpi", "qpp"],109+ };110+ for property in mode_properties {111+ let block = gst_property_block(&lines, property).with_context(|| {112+ format!(113+ "configured VA encoder {:?} has no {property} property",114+ config.vaapi_encoder115+ )116+ })?;117+ ensure!(118+ block.contains("writable") || block.contains("Writable"),119+ "configured VA encoder {:?} property {property} is not writable",120+ config.vaapi_encoder121+ );122+ }123+ Ok(())124+}125+126+fn gst_property_block(lines: &[&str], property: &str) -> Option<String> {127+ let start = lines.iter().position(|line| {128+ line.strip_prefix(" ").is_some_and(|line| {129+ line.starts_with(property) && line[property.len()..].starts_with(' ')130+ })131+ })?;132+ let end = lines[start + 1..]133+ .iter()134+ .position(|line| {135+ line.strip_prefix(" ")136+ .is_some_and(|line| !line.starts_with(' ') && line.split_once(" : ").is_some())137+ })138+ .map_or(lines.len(), |offset| start + 1 + offset);139+ Some(lines[start..end].join("\n"))140+}141+142+pub(crate) fn validate_executable(name: &str, path: &std::path::Path) -> Result<()> {143+ let metadata = fs::symlink_metadata(path)144+ .with_context(|| format!("inspecting gamescope_pipewire.{name} at {}", path.display()))?;145+ ensure!(146+ !metadata.file_type().is_symlink(),147+ "gamescope_pipewire.{name} must not be a symlink"148+ );149+ ensure!(150+ metadata.is_file(),151+ "gamescope_pipewire.{name} is not a file"152+ );153+ #[cfg(unix)]154+ {155+ use std::os::unix::fs::MetadataExt;156+157+ ensure!(158+ metadata.permissions().mode() & 0o111 != 0,159+ "gamescope_pipewire.{name} is not executable"160+ );161+ ensure!(162+ metadata.permissions().mode() & 0o022 == 0,163+ "gamescope_pipewire.{name} must not be writable by group or other users"164+ );165+ let owner = metadata.uid();166+ let effective_user = unsafe { libc::geteuid() };167+ ensure!(168+ owner == 0 || owner == effective_user,169+ "gamescope_pipewire.{name} must be owned by root or the current user"170+ );171+ }172+ Ok(())173+}174+175+pub(crate) async fn preflight_gstreamer_element(176+ gst_inspect_path: &std::path::Path,177+ element: &str,178+) -> Result<()> {179+ let mut command = Command::new(gst_inspect_path);180+ command.arg(element).env("LC_ALL", "C");181+ let output = run_bounded_command(command, MAX_INSPECT_OUTPUT).await?;182+ ensure!(183+ output.status.success(),184+ "GStreamer element {element:?} is unavailable: {}",185+ diagnostic(&output.stderr)186+ );187+ Ok(())188+}189+190+fn validate_amd_render_node(path: &std::path::Path) -> Result<()> {191+ let metadata = fs::symlink_metadata(path)192+ .with_context(|| format!("inspecting VAAPI render node {}", path.display()))?;193+ #[cfg(unix)]194+ ensure!(195+ metadata.file_type().is_char_device(),196+ "VAAPI render node {} is not a character device",197+ path.display()198+ );199+ fs::OpenOptions::new()200+ .read(true)201+ .write(true)202+ .open(path)203+ .with_context(|| {204+ format!(205+ "opening VAAPI render node {} read/write as the current user",206+ path.display()207+ )208+ })?;209+ #[cfg(target_os = "linux")]210+ {211+ let name = path212+ .file_name()213+ .context("VAAPI render node has no file name")?;214+ let driver = fs::canonicalize(215+ std::path::Path::new("/sys/class/drm")216+ .join(name)217+ .join("device/driver"),218+ )219+ .with_context(|| format!("resolving kernel driver for {}", path.display()))?;220+ ensure!(221+ driver.file_name().and_then(|name| name.to_str()) == Some("amdgpu"),222+ "VAAPI render node {} is not backed by amdgpu",223+ path.display()224+ );225+ }226+ Ok(())227+}228+229+#[cfg(test)]230+mod tests {231+ use super::super::tests::gamescope_config;232+ use super::*;233+234+ #[test]235+ fn requires_encoder_to_report_configured_render_node() {236+ let config = gamescope_config();237+ let pipewire = config.gamescope_pipewire.as_ref().unwrap();238+ let inspect = br#"239+Element Properties:240+ device-path : The DRM device path used for VA operation241+ flags: readable242+ String. Default: "/dev/dri/renderD128"243+ aud : Insert an AU delimiter for each frame244+ flags: readable, writable245+ b-frames : Number of B-frames246+ flags: readable, writable247+ bitrate : Target bitrate248+ flags: readable, writable249+ key-int-max : Maximum keyframe distance250+ flags: readable, writable251+ rate-control : Rate control mode252+ flags: readable, writable253+ Enum. Default: cbr254+ (2): cbr - Constant Bitrate255+ ref-frames : Number of reference frames256+ flags: readable, writable257+ target-usage : Speed and quality tradeoff258+ flags: readable, writable259+"#;260+ validate_encoder_inspection(inspect, pipewire).unwrap();261+262+ let index = inspect263+ .windows(b"renderD128".len())264+ .position(|window| window == b"renderD128")265+ .unwrap();266+ let mut wrong = inspect.to_vec();267+ wrong[index..index + b"renderD128".len()].copy_from_slice(b"renderD129");268+ assert!(validate_encoder_inspection(&wrong, pipewire).is_err());269+270+ let mut cqp = gamescope_config();271+ let pipewire = cqp.gamescope_pipewire.as_mut().unwrap();272+ pipewire.vaapi_encoder = "vah264lpenc".into();273+ pipewire.rate_control = VaapiRateControl::Cqp;274+ pipewire.bitrate_kbps = None;275+ pipewire.quantizer = Some(24);276+ let cqp_inspect = String::from_utf8_lossy(inspect)277+ .replace("Default: cbr", "Default: cqp")278+ .replace("(2): cbr", "(16): cqp")279+ .replace(280+ " ref-frames",281+ " qpi : I-frame quantizer\n flags: readable, writable\n qpp : P-frame quantizer\n flags: readable, writable\n ref-frames",282+ );283+ validate_encoder_inspection(cqp_inspect.as_bytes(), pipewire).unwrap();284+ }285+}