/// What the user chose to record. Switching from a window to a monitor always /// needs the user's choice; the recorder never broadens the target by itself. #[derive(Debug, Clone, PartialEq, Eq)] pub enum CaptureTarget { /// Windows: Game Capture hooked into one game window, the default for games. /// Elsewhere this is the same as `Window`. Game { window: WindowMatch }, /// A single window: Windows Window Capture, X11 Xcomposite, or a /// PipeWire window stream selected through the desktop portal on Wayland. Window { window: WindowMatch }, /// A whole monitor, including whatever is shown during alt-tab. Monitor { monitor: MonitorMatch }, } /// Identifies a window. On Windows this is OBS's `title:class:executable` /// string; on X11 it is the Xcomposite window id; on Wayland the portal picks /// the window, so only `restore_token` matters. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct WindowMatch { pub obs_window: String, pub restore_token: Option, } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct MonitorMatch { /// Windows monitor device id, or X11 screen number; unused on Wayland. pub obs_monitor: String, pub restore_token: Option, } #[derive(Debug, Clone, PartialEq)] pub enum SettingValue { String(String), Int(i64), Bool(bool), } /// An OBS source id with its settings. #[derive(Debug, Clone, PartialEq)] pub struct SourceSpec { pub id: &'static str, pub settings: Vec<(&'static str, SettingValue)>, } impl SourceSpec { fn new(id: &'static str) -> Self { Self { id, settings: Vec::new(), } } fn with(mut self, key: &'static str, value: SettingValue) -> Self { self.settings.push((key, value)); self } } /// Which Linux display server the recorder is running under. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LinuxSession { Wayland, X11, } impl LinuxSession { pub fn detect() -> Option { if std::env::var_os("WAYLAND_DISPLAY").is_some() { Some(Self::Wayland) } else if std::env::var_os("DISPLAY").is_some() { Some(Self::X11) } else { None } } } impl CaptureTarget { #[cfg(windows)] pub fn video_source(&self) -> SourceSpec { // libobs/util/windows/window-helpers.h: CLASS, TITLE, EXE. const WINDOW_PRIORITY_EXE: i64 = 2; use SettingValue::*; match self { Self::Game { window } => SourceSpec::new("game_capture") .with("capture_mode", String("window".into())) .with("window", String(window.obs_window.clone())) // Match the executable first: survives title changes. .with("priority", Int(WINDOW_PRIORITY_EXE)) .with("capture_cursor", Bool(false)), // Other Game Capture settings, including its anti-cheat // compatibility hook, keep OBS's defaults. Self::Window { window } => SourceSpec::new("window_capture") .with("window", String(window.obs_window.clone())) .with("priority", Int(WINDOW_PRIORITY_EXE)) .with("cursor", Bool(false)), Self::Monitor { monitor } => SourceSpec::new("monitor_capture") .with("monitor_id", String(monitor.obs_monitor.clone())) .with("capture_cursor", Bool(true)), } } #[cfg(not(windows))] pub fn video_source(&self, session: LinuxSession) -> SourceSpec { use SettingValue::*; match (session, self) { (LinuxSession::Wayland, Self::Game { window } | Self::Window { window }) => { portal_source( "pipewire-window-capture-source", window.restore_token.as_deref(), ) } (LinuxSession::Wayland, Self::Monitor { monitor }) => portal_source( "pipewire-screen-capture-source", monitor.restore_token.as_deref(), ), (LinuxSession::X11, Self::Game { window } | Self::Window { window }) => { SourceSpec::new("xcomposite_input") .with("capture_window", String(window.obs_window.clone())) .with("show_cursor", Bool(false)) } (LinuxSession::X11, Self::Monitor { monitor }) => SourceSpec::new("xshm_input_v2") .with("screen", Int(monitor.obs_monitor.parse().unwrap_or(0))) .with("show_cursor", Bool(true)), } } pub fn restore_token(&self) -> Option<&str> { match self { Self::Game { window } | Self::Window { window } => window.restore_token.as_deref(), Self::Monitor { monitor } => monitor.restore_token.as_deref(), } } } #[cfg(not(windows))] fn portal_source(id: &'static str, restore_token: Option<&str>) -> SourceSpec { let spec = SourceSpec::new(id).with("ShowCursor", SettingValue::Bool(true)); match restore_token { // Lets the portal restore the earlier selection without asking again. Some(token) => spec.with("RestoreToken", SettingValue::String(token.to_owned())), None => spec, } } /// Desktop audio and, when opted in, the default microphone. pub fn system_audio_source() -> SourceSpec { #[cfg(windows)] let id = "wasapi_output_capture"; #[cfg(not(windows))] let id = "pulse_output_capture"; SourceSpec::new(id).with("device_id", SettingValue::String("default".into())) } pub fn microphone_source() -> SourceSpec { #[cfg(windows)] let id = "wasapi_input_capture"; #[cfg(not(windows))] let id = "pulse_input_capture"; SourceSpec::new(id).with("device_id", SettingValue::String("default".into())) } #[cfg(test)] mod tests { use super::*; #[cfg(not(windows))] #[test] fn wayland_monitor_uses_the_portal_and_restores_the_selection() { let target = CaptureTarget::Monitor { monitor: MonitorMatch { obs_monitor: String::new(), restore_token: Some("tok".into()), }, }; let spec = target.video_source(LinuxSession::Wayland); assert_eq!(spec.id, "pipewire-screen-capture-source"); assert!( spec.settings .contains(&("RestoreToken", SettingValue::String("tok".into()))) ); } #[cfg(not(windows))] #[test] fn game_capture_is_window_capture_off_windows() { let window = WindowMatch::default(); let game = CaptureTarget::Game { window: window.clone(), }; let plain = CaptureTarget::Window { window }; for session in [LinuxSession::Wayland, LinuxSession::X11] { assert_eq!(game.video_source(session), plain.video_source(session)); } } #[cfg(windows)] #[test] fn game_target_uses_official_game_capture() { let target = CaptureTarget::Game { window: WindowMatch { obs_window: "Game:GameClass:game.exe".into(), restore_token: None, }, }; assert_eq!(target.video_source().id, "game_capture"); } }