ipc: Add screencast request and events for PipeWire casts

Allows desktop bars to show when screen recording is active.
This commit is contained in:
Ivan Molodetskikh
2026-01-12 18:25:31 +03:00
parent 9c79108afa
commit 238caaf8da
6 changed files with 259 additions and 3 deletions
+68
View File
@@ -117,6 +117,8 @@ pub enum Request {
ReturnError, ReturnError,
/// Request information about the overview. /// Request information about the overview.
OverviewState, OverviewState,
/// Request information about screencasts.
Casts,
} }
/// Reply from niri to client. /// Reply from niri to client.
@@ -161,6 +163,8 @@ pub enum Response {
OutputConfigChanged(OutputConfigChanged), OutputConfigChanged(OutputConfigChanged),
/// Information about the overview. /// Information about the overview.
OverviewState(Overview), OverviewState(Overview),
/// Information about screencasts.
Casts(Vec<Cast>),
} }
/// Overview information. /// Overview information.
@@ -1473,6 +1477,52 @@ pub struct LayerSurface {
pub keyboard_interactivity: LayerSurfaceKeyboardInteractivity, pub keyboard_interactivity: LayerSurfaceKeyboardInteractivity,
} }
/// A screencast.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct Cast {
/// Stream ID of the screencast that uniquely identifies it.
pub stream_id: u64,
/// Session ID of the screencast.
///
/// A session can have multiple screencast streams. Then multiple `Cast`s will have the same
/// `session_id`. Though, usually there's only one stream per session.
///
/// Do not confuse `session_id` with [`stream_id`](Self::stream_id).
pub session_id: u64,
/// Target being captured.
pub target: CastTarget,
/// Whether this is a Dynamic Cast Target screencast.
///
/// Meaning that actions like `SetDynamicCastWindow` will act on this screencast.
///
/// Keep in mind that the target can change even if this is `false`.
pub is_dynamic_target: bool,
/// Whether the cast is currently streaming frames.
///
/// This can be `false` for example when switching away to a different scene in OBS, which
/// pauses the stream.
pub is_active: bool,
}
/// Target of a screencast.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub enum CastTarget {
/// The target is not yet set, or was cleared.
Nothing {},
/// Casting an output.
Output {
/// Name of the screencasted output.
name: String,
},
/// Casting a window.
Window {
/// ID of the screencasted window.
id: u64,
},
}
/// A compositor event. /// A compositor event.
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
@@ -1595,6 +1645,24 @@ pub enum Event {
/// be converted to a `String` (e.g. contained invalid UTF-8 bytes). /// be converted to a `String` (e.g. contained invalid UTF-8 bytes).
path: Option<String>, path: Option<String>,
}, },
/// The screencasts have changed.
CastsChanged {
/// The new screencast information.
///
/// This configuration completely replaces the previous configuration. I.e. if any casts
/// are missing from here, then they were stopped.
casts: Vec<Cast>,
},
/// A screencast started, or an existing cast changed.
CastStartedOrChanged {
/// The cast that started or changed.
cast: Cast,
},
/// A screencast stopped.
CastStopped {
/// Stream ID of the stopped screencast.
stream_id: u64,
},
} }
impl From<Duration> for Timestamp { impl From<Duration> for Timestamp {
+37 -1
View File
@@ -9,7 +9,7 @@
use std::collections::hash_map::Entry; use std::collections::hash_map::Entry;
use std::collections::HashMap; use std::collections::HashMap;
use crate::{Event, KeyboardLayouts, Window, Workspace}; use crate::{Cast, Event, KeyboardLayouts, Window, Workspace};
/// Part of the state communicated via the event stream. /// Part of the state communicated via the event stream.
pub trait EventStreamStatePart { pub trait EventStreamStatePart {
@@ -46,6 +46,9 @@ pub struct EventStreamState {
/// State of the config. /// State of the config.
pub config: ConfigState, pub config: ConfigState,
/// State of screencasts.
pub casts: CastsState,
} }
/// The workspaces state communicated over the event stream. /// The workspaces state communicated over the event stream.
@@ -83,6 +86,13 @@ pub struct ConfigState {
pub failed: bool, pub failed: bool,
} }
/// The casts state communicated over the event stream.
#[derive(Debug, Default)]
pub struct CastsState {
/// Map from a stream id to the screencast.
pub casts: HashMap<u64, Cast>,
}
impl EventStreamStatePart for EventStreamState { impl EventStreamStatePart for EventStreamState {
fn replicate(&self) -> Vec<Event> { fn replicate(&self) -> Vec<Event> {
let mut events = Vec::new(); let mut events = Vec::new();
@@ -91,6 +101,7 @@ impl EventStreamStatePart for EventStreamState {
events.extend(self.keyboard_layouts.replicate()); events.extend(self.keyboard_layouts.replicate());
events.extend(self.overview.replicate()); events.extend(self.overview.replicate());
events.extend(self.config.replicate()); events.extend(self.config.replicate());
events.extend(self.casts.replicate());
events events
} }
@@ -100,6 +111,7 @@ impl EventStreamStatePart for EventStreamState {
let event = self.keyboard_layouts.apply(event)?; let event = self.keyboard_layouts.apply(event)?;
let event = self.overview.apply(event)?; let event = self.overview.apply(event)?;
let event = self.config.apply(event)?; let event = self.config.apply(event)?;
let event = self.casts.apply(event)?;
Some(event) Some(event)
} }
} }
@@ -285,3 +297,27 @@ impl EventStreamStatePart for ConfigState {
None None
} }
} }
impl EventStreamStatePart for CastsState {
fn replicate(&self) -> Vec<Event> {
let casts = self.casts.values().cloned().collect();
vec![Event::CastsChanged { casts }]
}
fn apply(&mut self, event: Event) -> Option<Event> {
match event {
Event::CastsChanged { casts } => {
self.casts = casts.into_iter().map(|c| (c.stream_id, c)).collect();
}
Event::CastStartedOrChanged { cast } => {
self.casts.insert(cast.stream_id, cast);
}
Event::CastStopped { stream_id } => {
let cast = self.casts.remove(&stream_id);
cast.expect("stopped cast was missing from the map");
}
event => return Some(event),
}
None
}
}
+2
View File
@@ -107,6 +107,8 @@ pub enum Msg {
RequestError, RequestError,
/// Print the overview state. /// Print the overview state.
OverviewState, OverviewState,
/// List screencasts.
Casts,
} }
#[derive(Clone, Debug, clap::ValueEnum)] #[derive(Clone, Debug, clap::ValueEnum)]
+56 -2
View File
@@ -7,8 +7,8 @@ use anyhow::{anyhow, bail, Context};
use niri_config::OutputName; use niri_config::OutputName;
use niri_ipc::socket::Socket; use niri_ipc::socket::Socket;
use niri_ipc::{ use niri_ipc::{
Action, Event, KeyboardLayouts, LogicalOutput, Mode, Output, OutputConfigChanged, Overview, Action, Cast, CastTarget, Event, KeyboardLayouts, LogicalOutput, Mode, Output,
Request, Response, Transform, Window, WindowLayout, OutputConfigChanged, Overview, Request, Response, Transform, Window, WindowLayout,
}; };
use serde_json::json; use serde_json::json;
@@ -48,6 +48,7 @@ pub fn handle_msg(mut msg: Msg, json: bool) -> anyhow::Result<()> {
Msg::EventStream => Request::EventStream, Msg::EventStream => Request::EventStream,
Msg::RequestError => Request::ReturnError, Msg::RequestError => Request::ReturnError,
Msg::OverviewState => Request::OverviewState, Msg::OverviewState => Request::OverviewState,
Msg::Casts => Request::Casts,
}; };
let mut socket = Socket::connect().context("error connecting to the niri socket")?; let mut socket = Socket::connect().context("error connecting to the niri socket")?;
@@ -496,6 +497,15 @@ pub fn handle_msg(mut msg: Msg, json: bool) -> anyhow::Result<()> {
let description = parts.join(" and "); let description = parts.join(" and ");
println!("Screenshot captured: {description}"); println!("Screenshot captured: {description}");
} }
Event::CastsChanged { casts } => {
println!("Casts changed: {casts:?}");
}
Event::CastStartedOrChanged { cast } => {
println!("Cast started or changed: {cast:?}");
}
Event::CastStopped { stream_id } => {
println!("Cast stopped: stream id {stream_id}");
}
} }
} }
} }
@@ -518,6 +528,28 @@ pub fn handle_msg(mut msg: Msg, json: bool) -> anyhow::Result<()> {
println!("Overview is closed."); println!("Overview is closed.");
} }
} }
Msg::Casts => {
let Response::Casts(mut casts) = response else {
bail!("unexpected response: expected Casts, got {response:?}");
};
if json {
let casts = serde_json::to_string(&casts).context("error formatting response")?;
println!("{casts}");
return Ok(());
}
if casts.is_empty() {
println!("No screencasts.");
return Ok(());
}
casts.sort_by_key(|c| (c.session_id, c.stream_id));
for cast in casts {
print_cast(&cast);
println!();
}
}
} }
Ok(()) Ok(())
@@ -706,6 +738,28 @@ fn print_window(window: &Window) {
); );
} }
fn print_cast(cast: &Cast) {
let active = if cast.is_active { "" } else { " (inactive)" };
println!("Cast stream ID {}:{active}", cast.stream_id);
println!(" Session ID: {}", cast.session_id);
match &cast.target {
CastTarget::Nothing {} => {
println!(" Target: nothing (cleared)");
}
CastTarget::Output { name } => {
println!(" Target: output \"{name}\"");
}
CastTarget::Window { id } => {
println!(" Target: window {id}");
}
}
if cast.is_dynamic_target {
println!(" Dynamic cast target");
}
}
fn fmt_rounded(x: f64) -> String { fn fmt_rounded(x: f64) -> String {
let r = x.round(); let r = x.round();
if (r - x).abs() <= 0.005 { if (r - x).abs() <= 0.005 {
+73
View File
@@ -450,6 +450,11 @@ async fn process(ctx: &ClientCtx, request: Request) -> Reply {
let is_open = state.overview.is_open; let is_open = state.overview.is_open;
Response::OverviewState(Overview { is_open }) Response::OverviewState(Overview { is_open })
} }
Request::Casts => {
let state = ctx.event_stream_state.borrow();
let casts = state.casts.casts.values().cloned().collect();
Response::Casts(casts)
}
}; };
Ok(response) Ok(response)
@@ -793,6 +798,74 @@ impl State {
server.send_event(event); server.send_event(event);
} }
#[cfg(feature = "xdp-gnome-screencast")]
pub fn ipc_refresh_casts(&mut self) {
let Some(server) = &self.niri.ipc_server else {
return;
};
let _span = tracy_client::span!("State::ipc_refresh_casts");
let mut state = server.event_stream_state.borrow_mut();
let state = &mut state.casts;
let mut events = Vec::new();
let mut seen = HashSet::new();
// Check pending dynamic casts.
for pending in &self.niri.casting.pending_dynamic_casts {
let stream_id = pending.stream_id.get();
seen.insert(stream_id);
// Pending dynamic casts don't change any properties, so we only need to check if it's
// missing from the state.
if !state.casts.contains_key(&stream_id) {
let cast = niri_ipc::Cast {
session_id: pending.session_id.get(),
stream_id,
target: niri_ipc::CastTarget::Nothing {},
is_dynamic_target: true,
is_active: false,
};
events.push(Event::CastStartedOrChanged { cast });
}
}
// Check active casts.
for cast in &self.niri.casting.casts {
let stream_id = cast.stream_id.get();
seen.insert(stream_id);
if state.casts.get(&stream_id).is_none_or(|existing| {
// Only these properties can change.
existing.is_active != cast.is_active() || !cast.target.matches(&existing.target)
}) {
let cast = niri_ipc::Cast {
session_id: cast.session_id.get(),
stream_id,
target: cast.target.make_ipc(),
is_dynamic_target: cast.dynamic_target,
is_active: cast.is_active(),
};
events.push(Event::CastStartedOrChanged { cast });
}
}
// Check for stopped casts.
for stream_id in state.casts.keys() {
if !seen.contains(stream_id) {
events.push(Event::CastStopped {
stream_id: *stream_id,
});
}
}
for event in events {
state.apply(event.clone());
server.send_event(event);
}
}
pub fn ipc_config_loaded(&mut self, failed: bool) { pub fn ipc_config_loaded(&mut self, failed: bool) {
let Some(server) = &self.niri.ipc_server else { let Some(server) = &self.niri.ipc_server else {
return; return;
+23
View File
@@ -592,6 +592,27 @@ impl CastTarget {
pub fn matches_output(&self, weak: &WeakOutput) -> bool { pub fn matches_output(&self, weak: &WeakOutput) -> bool {
matches!(self, CastTarget::Output { output, .. } if output == weak) matches!(self, CastTarget::Output { output, .. } if output == weak)
} }
pub fn matches(&self, ipc: &niri_ipc::CastTarget) -> bool {
use CastTarget::*;
match (self, ipc) {
(Nothing, niri_ipc::CastTarget::Nothing {}) => true,
(Output { name, .. }, niri_ipc::CastTarget::Output { name: ipc_name }) => {
name == ipc_name
}
(Window { id }, niri_ipc::CastTarget::Window { id: ipc_id }) => id == ipc_id,
_ => false,
}
}
pub fn make_ipc(&self) -> niri_ipc::CastTarget {
use CastTarget::*;
match self {
Nothing => niri_ipc::CastTarget::Nothing {},
Output { name, .. } => niri_ipc::CastTarget::Output { name: name.clone() },
Window { id } => niri_ipc::CastTarget::Window { id: *id },
}
}
} }
/// Pending update to a window's focus timestamp. /// Pending update to a window's focus timestamp.
@@ -795,6 +816,8 @@ impl State {
// screencasts. // screencasts.
#[cfg(feature = "xdp-gnome-screencast")] #[cfg(feature = "xdp-gnome-screencast")]
self.niri.refresh_mapped_cast_window_rules(); self.niri.refresh_mapped_cast_window_rules();
#[cfg(feature = "xdp-gnome-screencast")]
self.ipc_refresh_casts();
self.niri.refresh_window_rules(); self.niri.refresh_window_rules();
self.refresh_ipc_outputs(); self.refresh_ipc_outputs();