Support isolated live window ScreenCast streams
Render selected toplevels through a private portal-only capture protocol, preserve logical snapshot geometry, and renegotiate PipeWire video on window resizing. Bound outstanding snapshots and retry temporary contention; stop on source closure and session locking. Validation: portal unit tests (31), capture budget unit, private-bus portal contracts, and real nested/PipeWire isolation, workspace, resize, close and paused-lock tests pass. Independent review cleared. Monitor restore integration timed out on both this build and the unchanged previous compositor under the current nested host.
This commit is contained in:
1 parent
56a888adb3
commit
baca852ee9
26 files changed
+1307
-139
No files matched your search
+178
-23
@@ -78,6 +78,7 @@ struct Session {
|
||||
phase: Phase,
|
||||
multiple: bool,
|
||||
cursor: bool,
|
||||
source_types: u32,
|
||||
bar_controlled: bool,
|
||||
persist_mode: u32,
|
||||
restore: Option<crate::restore::Restore>,
|
||||
@@ -184,7 +185,7 @@ fn error(message: &str) -> zbus::fdo::Error {
|
||||
zbus::fdo::Error::InvalidArgs(message.into())
|
||||
}
|
||||
|
||||
fn source_options(options: &Options) -> zbus::fdo::Result<(bool, bool)> {
|
||||
fn source_options(options: &Options) -> zbus::fdo::Result<(bool, bool, u32)> {
|
||||
let number = |key: &str, default| {
|
||||
options
|
||||
.get(key)
|
||||
@@ -195,8 +196,8 @@ fn source_options(options: &Options) -> zbus::fdo::Result<(bool, bool)> {
|
||||
};
|
||||
let types = number("types", 1)?;
|
||||
let cursor = number("cursor_mode", 1)?;
|
||||
if types & 1 == 0 {
|
||||
return Err(error("Only monitor sharing is supported"));
|
||||
if types == 0 || types & !3 != 0 {
|
||||
return Err(error("Unsupported source types"));
|
||||
}
|
||||
if cursor != 1 && cursor != 2 {
|
||||
return Err(error("Unsupported cursor mode"));
|
||||
@@ -208,7 +209,7 @@ fn source_options(options: &Options) -> zbus::fdo::Result<(bool, bool)> {
|
||||
.map_err(|_| error("Invalid multiple option"))?
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok((multiple, cursor == 2))
|
||||
Ok((multiple, cursor == 2, types))
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
@@ -241,7 +242,7 @@ impl Backend {
|
||||
|
||||
#[zbus(property)]
|
||||
fn available_source_types(&self) -> u32 {
|
||||
1
|
||||
3
|
||||
}
|
||||
|
||||
#[zbus(property)]
|
||||
@@ -294,6 +295,7 @@ impl Backend {
|
||||
phase: Phase::Created,
|
||||
multiple: false,
|
||||
cursor: false,
|
||||
source_types: 1,
|
||||
bar_controlled: false,
|
||||
persist_mode: 0,
|
||||
restore: None,
|
||||
@@ -313,8 +315,13 @@ impl Backend {
|
||||
#[zbus(header)] header: Header<'_>,
|
||||
) -> zbus::fdo::Result<(u32, Options)> {
|
||||
let owner = authorize(connection, &header).await?;
|
||||
let settings = source_options(&options).and_then(|(multiple, cursor)| {
|
||||
Ok((multiple, cursor, crate::restore::persist_mode(&options)?))
|
||||
let settings = source_options(&options).and_then(|(multiple, cursor, types)| {
|
||||
Ok((
|
||||
multiple,
|
||||
cursor,
|
||||
types,
|
||||
crate::restore::persist_mode(&options)?,
|
||||
))
|
||||
});
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
@@ -329,11 +336,12 @@ impl Backend {
|
||||
}
|
||||
|
||||
match settings {
|
||||
Ok((multiple, cursor, persist_mode)) => {
|
||||
Ok((multiple, cursor, types, persist_mode)) => {
|
||||
session.persist_mode = persist_mode;
|
||||
session.restore = crate::restore::Restore::read(&options);
|
||||
session.multiple = multiple;
|
||||
session.cursor = cursor;
|
||||
session.source_types = types;
|
||||
session.phase = Phase::Selected;
|
||||
Ok((0, Options::new()))
|
||||
}
|
||||
@@ -365,7 +373,7 @@ impl Backend {
|
||||
return Err(error("Invalid request path"));
|
||||
}
|
||||
|
||||
let (multiple, cursor, bar_controlled, persist_mode, restore, cancel) = {
|
||||
let (multiple, cursor, source_types, bar_controlled, persist_mode, restore, cancel) = {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get_mut(session_handle.as_str())
|
||||
@@ -379,6 +387,7 @@ impl Backend {
|
||||
(
|
||||
session.multiple,
|
||||
session.cursor,
|
||||
session.source_types,
|
||||
session.bar_controlled,
|
||||
session.persist_mode,
|
||||
session.restore.clone(),
|
||||
@@ -412,6 +421,7 @@ impl Backend {
|
||||
&parent_window,
|
||||
multiple,
|
||||
cursor,
|
||||
source_types,
|
||||
persist_mode,
|
||||
restore,
|
||||
bar_controlled,
|
||||
@@ -559,6 +569,7 @@ async fn start_streams(
|
||||
parent: &str,
|
||||
multiple: bool,
|
||||
cursor: bool,
|
||||
source_types: u32,
|
||||
requested_persistence: u32,
|
||||
restore: Option<crate::restore::Restore>,
|
||||
bar_controlled: bool,
|
||||
@@ -566,7 +577,15 @@ async fn start_streams(
|
||||
let mut discovery = tokio::task::spawn_blocking(|| Capture::connect(&AtomicBool::new(false)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
let sources = discovery.sources();
|
||||
let mut sources = if source_types & 1 != 0 {
|
||||
discovery.sources()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if source_types & 2 != 0 && discovery.supports_windows() {
|
||||
let windows = crate::desktop::ipc("get-windows", serde_json::json!({})).await?;
|
||||
sources.extend(crate::desktop::sharing_window_sources(&windows)?);
|
||||
}
|
||||
let outputs = crate::desktop::ipc("get-outputs", serde_json::json!({})).await?;
|
||||
let rememberable = sources
|
||||
.iter()
|
||||
@@ -578,6 +597,15 @@ async fn start_streams(
|
||||
.collect::<Vec<_>>();
|
||||
let restored = restore.and_then(|restore| {
|
||||
let selected = restore.resolve(app, cursor, multiple, &sources, &outputs)?;
|
||||
let selected = validate_selection(
|
||||
&sources,
|
||||
&selected
|
||||
.iter()
|
||||
.map(|source| source.name.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
multiple,
|
||||
)
|
||||
.ok()?;
|
||||
Some((selected, restore.effective_mode(requested_persistence)))
|
||||
});
|
||||
let (selected, persist_mode) = if let Some(restored) = restored {
|
||||
@@ -616,7 +644,12 @@ async fn start_streams(
|
||||
let mut reply = Options::new();
|
||||
let fresh_outputs = crate::desktop::ipc("get-outputs", serde_json::json!({})).await?;
|
||||
|
||||
if !crate::restore::selection_unchanged(&selected, &outputs, &fresh_outputs) {
|
||||
let monitors = selected
|
||||
.iter()
|
||||
.filter(|source| source.window_id().is_none())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if !crate::restore::selection_unchanged(&monitors, &outputs, &fresh_outputs) {
|
||||
return Err("A selected display changed while approval was pending".into());
|
||||
}
|
||||
|
||||
@@ -629,7 +662,7 @@ async fn start_streams(
|
||||
};
|
||||
reply.insert("persist_mode".into(), effective_persistence.into());
|
||||
|
||||
let generations = selected
|
||||
let generations = monitors
|
||||
.iter()
|
||||
.map(|source| {
|
||||
discovery
|
||||
@@ -641,14 +674,28 @@ async fn start_streams(
|
||||
let mut children = Vec::new();
|
||||
let mut streams = Vec::new();
|
||||
|
||||
for (source, (_, generation)) in selected.iter().zip(&generations) {
|
||||
let mut child = child_command()?
|
||||
.args([
|
||||
for source in &selected {
|
||||
let mut command = child_command()?;
|
||||
if let Some(id) = source.window_id() {
|
||||
command.args([
|
||||
"--stream-window",
|
||||
&id.to_string(),
|
||||
if cursor { "embedded" } else { "hidden" },
|
||||
]);
|
||||
} else {
|
||||
let generation = generations
|
||||
.iter()
|
||||
.find(|(name, _)| name == &source.name)
|
||||
.ok_or("Missing display generation")?
|
||||
.1;
|
||||
command.args([
|
||||
"--stream",
|
||||
&source.name,
|
||||
if cursor { "embedded" } else { "hidden" },
|
||||
&generation.to_string(),
|
||||
])
|
||||
]);
|
||||
}
|
||||
let mut child = command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
@@ -680,7 +727,7 @@ async fn start_streams(
|
||||
.map_err(|error| error.to_string())??;
|
||||
|
||||
let final_outputs = crate::desktop::ipc("get-outputs", serde_json::json!({})).await?;
|
||||
if !crate::restore::selection_unchanged(&selected, &outputs, &final_outputs) {
|
||||
if !crate::restore::selection_unchanged(&monitors, &outputs, &final_outputs) {
|
||||
return Err("A selected display changed during stream startup".into());
|
||||
}
|
||||
|
||||
@@ -691,16 +738,59 @@ async fn start_streams(
|
||||
return Err("A selected stream ended during startup".into());
|
||||
}
|
||||
|
||||
let final_windows = crate::desktop::ipc("get-windows", serde_json::json!({})).await?;
|
||||
let streams = streams
|
||||
.into_iter()
|
||||
.map(|(name, ready)| {
|
||||
monitor_metadata(&final_outputs, &name, &ready).map(|metadata| (ready.node, metadata))
|
||||
let metadata = if name.starts_with("window:") {
|
||||
let source = selected
|
||||
.iter()
|
||||
.find(|source| source.name == name)
|
||||
.ok_or("Unknown window stream")?;
|
||||
let id = source.window_id().ok_or("Invalid selected window")?;
|
||||
if !final_windows.as_array().is_some_and(|windows| {
|
||||
windows
|
||||
.iter()
|
||||
.any(|window| window["id"].as_u64() == Some(id))
|
||||
}) {
|
||||
return Err("A selected window closed during startup".to_owned());
|
||||
}
|
||||
window_metadata(source, &ready)
|
||||
} else {
|
||||
monitor_metadata(&final_outputs, &name, &ready)
|
||||
};
|
||||
metadata.map(|metadata| (ready.node, metadata))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok((children, streams, reply))
|
||||
}
|
||||
|
||||
fn window_metadata(source: &Source, ready: &Ready) -> Result<Options, String> {
|
||||
if source.window_id().is_none() || ready.width == 0 || ready.height == 0 {
|
||||
return Err("Invalid window stream".into());
|
||||
}
|
||||
let (width, height) = ready
|
||||
.logical_size
|
||||
.ok_or("Missing captured window geometry")?;
|
||||
let size = (
|
||||
i32::try_from(width).map_err(|_| "Invalid window width")?,
|
||||
i32::try_from(height).map_err(|_| "Invalid window height")?,
|
||||
);
|
||||
if size.0 <= 0 || size.1 <= 0 {
|
||||
return Err("Invalid captured window geometry".into());
|
||||
}
|
||||
Ok(Options::from([
|
||||
("source_type".into(), 2u32.into()),
|
||||
(
|
||||
"size".into(),
|
||||
Value::from(size)
|
||||
.try_to_owned()
|
||||
.map_err(|e| e.to_string())?,
|
||||
),
|
||||
]))
|
||||
}
|
||||
|
||||
fn monitor_metadata(
|
||||
outputs: &serde_json::Value,
|
||||
name: &str,
|
||||
@@ -770,7 +860,11 @@ fn validate_selection(
|
||||
names: &[String],
|
||||
multiple: bool,
|
||||
) -> Result<Vec<Source>, String> {
|
||||
if names.is_empty() || names.len() > sources.len() || (!multiple && names.len() != 1) {
|
||||
if names.is_empty()
|
||||
|| names.len() > 8
|
||||
|| names.len() > sources.len()
|
||||
|| (!multiple && names.len() != 1)
|
||||
{
|
||||
return Err("Invalid display selection".into());
|
||||
}
|
||||
|
||||
@@ -893,19 +987,78 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rejects_unimplemented_sources_and_cursor_modes() {
|
||||
assert_eq!(source_options(&Options::new()).unwrap(), (false, false));
|
||||
for bits in [0u32, 2, 4, 6] {
|
||||
assert_eq!(source_options(&Options::new()).unwrap(), (false, false, 1));
|
||||
for bits in [0u32, 4, 5, 6, 7] {
|
||||
assert!(source_options(&HashMap::from([("types".into(), bits.into())])).is_err());
|
||||
}
|
||||
for bits in [1u32, 3, 5, 7] {
|
||||
for bits in [1u32, 2, 3] {
|
||||
assert_eq!(
|
||||
source_options(&HashMap::from([("types".into(), bits.into())])).unwrap(),
|
||||
(false, false)
|
||||
(false, false, bits)
|
||||
);
|
||||
}
|
||||
assert!(source_options(&HashMap::from([("cursor_mode".into(), 4u32.into())])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_metadata_uses_captured_logical_geometry_and_omits_position() {
|
||||
let source = Source {
|
||||
name: "window:17".into(),
|
||||
label: "Editor".into(),
|
||||
width: 640,
|
||||
height: 480,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
};
|
||||
let ready = Ready {
|
||||
node: 1,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
logical_size: Some((600, 400)),
|
||||
};
|
||||
let metadata = window_metadata(&source, &ready).unwrap();
|
||||
assert_eq!(u32::try_from(&metadata["source_type"]).unwrap(), 2);
|
||||
assert_eq!(
|
||||
<(i32, i32)>::try_from(metadata["size"].try_clone().unwrap()).unwrap(),
|
||||
(600, 400)
|
||||
);
|
||||
assert!(!metadata.contains_key("position"));
|
||||
assert!(
|
||||
window_metadata(
|
||||
&source,
|
||||
&Ready {
|
||||
logical_size: None,
|
||||
..ready
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_selection_is_bounded_and_accepts_displays_with_windows() {
|
||||
let sources = (0..9)
|
||||
.map(|id| Source {
|
||||
name: format!("window:{id}"),
|
||||
label: "Editor".into(),
|
||||
width: 640,
|
||||
height: 480,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let names = sources
|
||||
.iter()
|
||||
.map(|source| source.name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(validate_selection(&sources, &names[..8], true).is_ok());
|
||||
assert!(validate_selection(&sources, &names, true).is_err());
|
||||
assert!(validate_selection(&sources, &names[..2], false).is_err());
|
||||
assert!(validate_selection(&sources, &[names[0].clone(), names[0].clone()], true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitor_metadata_uses_logical_fractional_scale_geometry() {
|
||||
let outputs = serde_json::json!([{"name":"display","enabled":true,"x":-1440,"y":20,"width":2560,"height":1440,"scale":1.5,"current_mode":{"width":3840,"height":2160}}]);
|
||||
@@ -913,6 +1066,7 @@ mod tests {
|
||||
&outputs,
|
||||
"display",
|
||||
&Ready {
|
||||
logical_size: None,
|
||||
node: 1,
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
@@ -932,6 +1086,7 @@ mod tests {
|
||||
&outputs,
|
||||
"missing",
|
||||
&Ready {
|
||||
logical_size: None,
|
||||
node: 1,
|
||||
width: 3840,
|
||||
height: 2160
|
||||
|
||||
Reference in new issue
Block a user