Add workspace screen sharing (#64)

Add workspace selection to the screen-sharing picker with pinned capture, privacy filtering and bounded capture resources. Refresh captured tab labels when title or app-ID metadata changes.
This commit is contained in:
Samuel Onoja authored and GitHub committed 2026-10-25 13:59:34 +01:00
1 parent e64ab52190
commit ab11269246
32 files changed
+2125 -165

No files matched your search

+42 -4
View File
@@ -704,6 +704,16 @@ async fn start_streams(
sources.extend(crate::desktop::sharing_window_sources(&windows)?);
}
let outputs = capture_ipc(&cancel, &work, "get-outputs", serde_json::json!({})).await?;
let workspaces = if source_types & 1 != 0 && !bar_controlled && discovery.supports_workspaces()
{
let workspaces =
capture_ipc(&cancel, &work, "get-workspaces", serde_json::json!({})).await?;
sources.extend(crate::workspace::sources(&workspaces, &outputs)?);
Some(workspaces)
} else {
None
};
sources.sort_by_key(|source| source.kind().category());
let rememberable = sources
.iter()
.filter(|source| {
@@ -769,7 +779,7 @@ async fn start_streams(
.await?;
let region = if selection.region {
if selected.len() != 1 || selected[0].window_id().is_some() {
if selected.len() != 1 || !selected[0].is_monitor() {
return Err("Region recording requires one display".into());
}
let mut command = child_command_for("slurp")?;
@@ -808,7 +818,7 @@ async fn start_streams(
let monitors = selected
.iter()
.filter(|source| source.window_id().is_none())
.filter(|source| source.is_monitor())
.cloned()
.collect::<Vec<_>>();
if !crate::restore::selection_unchanged(&monitors, &outputs, &fresh_outputs) {
@@ -838,7 +848,13 @@ async fn start_streams(
for source in &selected {
let mut command = child_command()?;
if let Some(id) = source.window_id() {
if let Some(id) = source.workspace_id() {
command.args([
"--stream-workspace",
&id.to_string(),
if cursor { "embedded" } else { "hidden" },
]);
} else if let Some(id) = source.window_id() {
command.args([
"--stream-window",
&id.to_string(),
@@ -904,6 +920,18 @@ async fn start_streams(
return Err("A selected stream ended during startup".into());
}
if let Some(before) = &workspaces {
let after = capture_ipc(&cancel, &work, "get-workspaces", serde_json::json!({})).await?;
if !crate::workspace::selection_unchanged(
&selected,
before,
&outputs,
&after,
&final_outputs,
) {
return Err("A selected workspace changed during approval or startup".into());
}
}
let final_windows = capture_ipc(&cancel, &work, "get-windows", serde_json::json!({})).await?;
let recording = if bar_controlled {
let crop = region
@@ -941,7 +969,17 @@ async fn start_streams(
let streams = streams
.into_iter()
.map(|(name, ready)| {
let metadata = if name.starts_with("window:") {
let metadata = if let crate::capture::SourceKind::Workspace(_) =
crate::capture::SourceKind::parse(&name)
{
let source = selected
.iter()
.find(|source| source.name == name)
.ok_or("Unknown workspace stream")?;
crate::workspace::metadata(source, &ready)
} else if let crate::capture::SourceKind::Window(_) =
crate::capture::SourceKind::parse(&name)
{
let source = selected
.iter()
.find(|source| source.name == name)
+63 -3
View File
@@ -6,7 +6,7 @@ use std::time::{Duration, Instant};
use ferese_protocols::window_capture::v1::client::ferese_window_capture_manager_v1 as window_manager;
use serde::{Deserialize, Serialize};
use wayland_client::protocol::{wl_output, wl_registry, wl_shm};
use wayland_client::{Connection, Dispatch, EventQueue, QueueHandle, WEnum};
use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle, WEnum};
use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1 as manager;
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -20,9 +20,62 @@ pub struct Source {
pub scale: i32,
}
/// Decode opaque worker names in one place. Unknown/malformed private names
/// must never fall through to monitor capture or remembered display consent.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum SourceKind {
Monitor,
Window(u64),
Workspace(u64),
Invalid,
}
impl SourceKind {
pub fn parse(name: &str) -> Self {
if let Some(id) = name.strip_prefix("window:") {
id.parse().map(Self::Window).unwrap_or(Self::Invalid)
} else if let Some(id) = name.strip_prefix("workspace:") {
id.parse().map(Self::Workspace).unwrap_or(Self::Invalid)
} else {
Self::Monitor
}
}
pub fn label(self) -> &'static str {
match self {
Self::Monitor => "Displays",
Self::Window(_) => "Windows",
Self::Workspace(_) => "Workspaces",
Self::Invalid => "Unavailable",
}
}
pub fn category(self) -> u8 {
match self {
Self::Monitor => 0,
Self::Workspace(_) => 1,
Self::Window(_) => 2,
Self::Invalid => 3,
}
}
}
impl Source {
pub fn kind(&self) -> SourceKind {
SourceKind::parse(&self.name)
}
pub fn is_monitor(&self) -> bool {
self.kind() == SourceKind::Monitor
}
pub fn window_id(&self) -> Option<u64> {
self.name.strip_prefix("window:")?.parse().ok()
if let SourceKind::Window(id) = self.kind() {
Some(id)
} else {
None
}
}
pub fn workspace_id(&self) -> Option<u64> {
if let SourceKind::Workspace(id) = self.kind() {
Some(id)
} else {
None
}
}
}
@@ -85,6 +138,13 @@ impl Capture {
Ok(())
}
pub fn supports_workspaces(&self) -> bool {
self.state
.window_manager
.as_ref()
.is_some_and(|manager| manager.version() >= 3)
}
pub fn supports_windows(&self) -> bool {
self.state.window_manager.is_some()
}
@@ -195,7 +255,7 @@ impl Dispatch<wl_registry::WlRegistry, ()> for State {
},
}),
"ferese_window_capture_manager_v1" => {
state.window_manager = Some(registry.bind(name, 1, qh, ()))
state.window_manager = Some(registry.bind(name, version.min(3), qh, ()))
}
"wl_shm" => state.shm = Some(registry.bind(name, 1, qh, ())),
"zwlr_screencopy_manager_v1" => {
+5
View File
@@ -16,6 +16,7 @@ mod restore;
mod settings;
mod shortcuts;
mod stream;
mod workspace;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().skip(1).collect();
@@ -24,6 +25,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
[flag, name, cursor] if flag == "--stream" => {
stream::run(name.clone(), cursor == "embedded", None)
}
[flag, id, cursor] if flag == "--stream-workspace" => {
let id: u64 = id.parse()?;
stream::run(format!("workspace:{id}"), cursor == "embedded", None)
}
[flag, id, cursor] if flag == "--stream-window" => {
let id: u64 = id.parse()?;
stream::run(format!("window:{id}"), cursor == "embedded", None)
+15 -18
View File
@@ -34,10 +34,10 @@ impl Prompt {
let mut previous_kind = None;
if !self.window_capture {
for source in visible {
let is_window = source.window_id().is_some();
if previous_kind != Some(is_window) {
let kind = source.kind().category();
if previous_kind != Some(kind) {
sections += 1;
previous_kind = Some(is_window);
previous_kind = Some(kind);
}
}
}
@@ -94,7 +94,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
}
let prompt: Prompt = serde_json::from_str(&input)?;
if prompt.sources.is_empty() || prompt.sources.len() > 144 {
if prompt.sources.is_empty() || prompt.sources.len() > 272 {
return Err("No shareable displays".into());
}
@@ -328,25 +328,20 @@ impl cosmic::Application for Picker {
let mut sources = column([]).spacing(6);
let mut previous_kind = None;
for (index, source) in self.prompt.sources.iter().enumerate() {
let is_window = self.prompt.window_capture || source.window_id().is_some();
if !self.prompt.window_capture && previous_kind != Some(is_window) {
sources = sources.push(
self.text(if is_window { "Windows" } else { "Displays" })
.size(12),
);
previous_kind = Some(is_window);
let kind = source.kind().category();
if !self.prompt.window_capture && previous_kind != Some(kind) {
sources = sources.push(self.text(source.kind().label()).size(12));
previous_kind = Some(kind);
}
let selected = self.selected.contains(&index);
let mut entry = row![
glyph(ferese_theme::icons::DISPLAY, 24),
column![
self.text(
if self.prompt.window_capture || source.window_id().is_some() {
&source.label
} else {
&source.name
}
)
self.text(if self.prompt.window_capture || !source.is_monitor() {
&source.label
} else {
&source.name
})
.size(14),
self.text(format!("{} × {}", source.width, source.height))
.size(12)
@@ -377,6 +372,8 @@ impl cosmic::Application for Picker {
"Choose a display, then record it in full or select a region."
} else if self.prompt.window_capture {
"Only the selected window will be captured."
} else if self.prompt.sources.iter().any(|source| source.workspace_id().is_some()) {
"A workspace stays shared when you switch away. Desktop panels and notifications are excluded."
} else {
"Displays share everything visible. Windows share only their own content."
})
+1
View File
@@ -148,6 +148,7 @@ impl Restore {
|| app.len() > 512
|| sources.is_empty()
|| sources.len() > 16
|| sources.iter().any(|source| !source.is_monitor())
|| !(1..=2).contains(&mode)
{
return None;
+16 -1
View File
@@ -88,6 +88,11 @@ impl Data {
eprintln!("DMA-BUF capture unavailable ({error}); using shared memory");
self.force_shm = true;
self.renegotiate = true;
// Retry unchanged content after a failed GPU transfer.
if let Err(error) = self.capture.borrow_mut().refresh() {
eprintln!("Could not refresh fallback capture: {error}");
self.stop.store(true, Ordering::Relaxed);
}
} else {
eprintln!("{error}");
self.stop.store(true, Ordering::Relaxed);
@@ -325,6 +330,10 @@ pub fn run(
.state_changed(move |stream, data, _, state| {
let mut data = data.borrow_mut();
let active = matches!(state, pw::stream::StreamState::Streaming);
if let Err(error) = data.capture.borrow_mut().set_active(active) {
eprintln!("Could not update capture activity: {error}");
data.stop.store(true, Ordering::Relaxed);
}
if active
&& !data.active
&& let Err(error) = data.capture.borrow_mut().refresh()
@@ -559,7 +568,13 @@ pub fn run(
data.borrow_mut().complete(&stream, completion);
}
let changed = capture.borrow_mut().take_changed();
if changed && !name.starts_with("window:") && capture.borrow().constraints().size != size {
if changed
&& !matches!(
crate::capture::SourceKind::parse(&name),
crate::capture::SourceKind::Window(_)
)
&& capture.borrow().constraints().size != size
{
eprintln!("Shared monitor changed size; start sharing again");
break;
}
+54 -22
View File
@@ -7,7 +7,7 @@ use std::time::{Duration, Instant};
use ferese_protocols::window_capture::v1::client::ferese_window_capture_manager_v1 as window_manager;
use wayland_client::protocol::{wl_buffer, wl_output, wl_registry, wl_shm, wl_shm_pool};
use wayland_client::{Connection, Dispatch, EventQueue, QueueHandle, WEnum};
use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle, WEnum};
use wayland_protocols::ext::image_capture_source::v1::client::{
ext_image_capture_source_v1 as source,
ext_output_image_capture_source_manager_v1 as output_manager,
@@ -94,6 +94,7 @@ pub(super) struct Capture {
frame: Option<frame::ExtImageCopyCaptureFrameV1>,
pub device: Option<gbm::Device<File>>,
device_id: Option<u64>,
workspace: bool,
}
impl Capture {
@@ -121,30 +122,37 @@ impl Capture {
dispatch(&connection, &mut queue, &mut state, 20)?;
}
}
let source = if let Some(id) = name
.strip_prefix("window:")
.and_then(|value| value.parse::<u64>().ok())
{
state
let source = match crate::capture::SourceKind::parse(name) {
crate::capture::SourceKind::Window(id) => state
.windows
.as_ref()
.ok_or("Persistent window capture is unavailable")?
.create_source((id >> 32) as u32, id as u32, &qh, ())
} else {
let (&id, (output, _)) = state
.outputs
.iter()
.find(|(_, (_, output_name))| output_name == name)
.ok_or("Shared display disconnected")?;
if generation.is_some_and(|generation| generation != id) {
return Err("Shared display was replaced".into());
.create_source((id >> 32) as u32, id as u32, &qh, ()),
crate::capture::SourceKind::Workspace(id) => {
let manager = state
.windows
.as_ref()
.filter(|manager| manager.version() >= 3)
.ok_or("Workspace capture is unavailable")?;
manager.create_workspace_source((id >> 32) as u32, id as u32, &qh, ())
}
crate::capture::SourceKind::Invalid => return Err("Invalid capture source".into()),
crate::capture::SourceKind::Monitor => {
let (&id, (output, _)) = state
.outputs
.iter()
.find(|(_, (_, output_name))| output_name == name)
.ok_or("Shared display disconnected")?;
if generation.is_some_and(|generation| generation != id) {
return Err("Shared display was replaced".into());
}
state.selected = Some(id);
state
.output_manager
.as_ref()
.ok_or("Persistent display capture is unavailable")?
.create_source(output, &qh, ())
}
state.selected = Some(id);
state
.output_manager
.as_ref()
.ok_or("Persistent display capture is unavailable")?
.create_source(output, &qh, ())
};
let options = if cursor {
manager::Options::PaintCursors
@@ -184,6 +192,10 @@ impl Capture {
frame: None,
device,
device_id,
workspace: matches!(
crate::capture::SourceKind::parse(name),
crate::capture::SourceKind::Workspace(_)
),
})
}
@@ -231,6 +243,18 @@ impl Capture {
}
Some(completion)
}
pub(super) fn set_active(&mut self, active: bool) -> Result<(), String> {
if self.workspace {
self.state
.windows
.as_ref()
.ok_or("Workspace capture manager disappeared")?
.set_source_active(&self.source, u32::from(active));
self.connection.flush().map_err(|error| error.to_string())?;
}
Ok(())
}
pub(super) fn refresh(&mut self) -> Result<(), String> {
self.state
.windows
@@ -271,6 +295,14 @@ impl Capture {
}
let fd = bo.fd().map_err(|error| error.to_string())?;
let stride = bo.stride_for_plane(0);
let max_stride = width
.checked_mul(4)
.and_then(|row| row.checked_add(4095))
.map(|row| row & !4095)
.ok_or("DMA-BUF row is too large")?;
if stride > max_stride {
return Err("DMA-BUF stride exceeds the capture memory reservation".into());
}
let offset = bo.offset(0);
if offset != 0 {
return Err("Unsupported DMA-BUF plane offset".into());
@@ -453,7 +485,7 @@ impl Dispatch<wl_registry::WlRegistry, ()> for State {
state.output_manager = Some(registry.bind(name, 1, qh, ()))
}
"ferese_window_capture_manager_v1" if version >= 2 => {
state.windows = Some(registry.bind(name, 2, qh, ()))
state.windows = Some(registry.bind(name, version.min(3), qh, ()))
}
"wl_shm" => state.shm = Some(registry.bind(name, 1, qh, ())),
"zwp_linux_dmabuf_v1" if version >= 3 => {
+177
View File
@@ -0,0 +1,177 @@
//! Portal compatibility: a workspace is an explicitly chosen, restricted view
//! of an existing monitor. Offer it only when MONITOR was requested; never
//! advertise an invented WORKSPACE bit or VIRTUAL (which creates a new monitor).
use crate::backend::Options;
use crate::capture::Source;
use crate::stream::Ready;
use serde_json::Value as Json;
use zbus::zvariant::Value;
pub(crate) fn sources(workspaces: &Json, outputs: &Json) -> Result<Vec<Source>, String> {
let workspaces = workspaces.as_array().ok_or("Invalid workspace list")?;
let outputs = outputs.as_array().ok_or("Invalid output list")?;
Ok(workspaces
.iter()
.take(128)
.filter_map(|workspace| {
let id = workspace["id"].as_u64()?;
let owner = workspace["output"].as_u64()?;
let output = outputs
.iter()
.find(|output| output["id"].as_u64() == Some(owner) && output["enabled"] == true)?;
let integer = |key: &str| {
output[key]
.as_i64()
.and_then(|value| i32::try_from(value).ok())
};
let (width, height) = (integer("width")?, integer("height")?);
if width <= 0 || height <= 0 {
return None;
}
Some(Source {
name: format!("workspace:{id}"),
label: format!(
"Workspace {} · {}",
workspace["index"].as_u64()?,
output["name"].as_str()?
),
width,
height,
x: integer("x")?,
y: integer("y")?,
scale: 1,
})
})
.collect())
}
pub(crate) fn selection_unchanged(
selected: &[Source],
before: &Json,
old_outputs: &Json,
after: &Json,
new_outputs: &Json,
) -> bool {
selected.iter().filter_map(Source::workspace_id).all(|id| {
let owner = |workspaces: &Json| {
workspaces
.as_array()?
.iter()
.find(|workspace| workspace["id"].as_u64() == Some(id))?["output"]
.as_u64()
};
let Some(old_owner) = owner(before) else {
return false;
};
if owner(after) != Some(old_owner) {
return false;
}
let output = |outputs: &Json| {
outputs
.as_array()?
.iter()
.find(|output| output["id"].as_u64() == Some(old_owner))
.cloned()
};
let (Some(old), Some(new)) = (output(old_outputs), output(new_outputs)) else {
return false;
};
new["enabled"] == true
&& [
"identity",
"name",
"width",
"height",
"x",
"y",
"scale",
"transform",
"current_mode",
]
.into_iter()
.all(|key| old[key] == new[key])
})
}
pub(crate) fn metadata(source: &Source, ready: &Ready) -> Result<Options, String> {
if source.workspace_id().is_none()
|| ready.width == 0
|| ready.height == 0
|| ready.logical_size != Some((source.width as u32, source.height as u32))
{
return Err("Invalid workspace stream geometry".into());
}
Ok(Options::from([
("source_type".into(), 1u32.into()),
(
"size".into(),
Value::from((source.width, source.height))
.try_to_owned()
.map_err(|error| error.to_string())?,
),
]))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capture::SourceKind;
use serde_json::json;
fn outputs() -> Json {
json!([{"id": 5, "name": "DP-1", "enabled": true, "width": 1920, "height": 1080,
"x": 0, "y": 0, "scale": 2.0, "identity": "drm-edid:test", "transform": "normal",
"current_mode": {"width": 3840, "height": 2160}}])
}
#[test]
fn workspace_selection_uses_identity_not_index_and_never_remembers_consent() {
let outputs = outputs();
let before = json!([{"id": 42, "output": 5, "index": 1}]);
let selected = sources(&before, &outputs).unwrap();
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].kind(), SourceKind::Workspace(42));
assert_eq!(selected[0].label, "Workspace 1 · DP-1");
let renamed = json!([{"id": 42, "output": 5, "index": 3}]);
assert!(selection_unchanged(
&selected, &before, &outputs, &renamed, &outputs
));
let replaced = json!([{"id": 43, "output": 5, "index": 1}]);
assert!(!selection_unchanged(
&selected, &before, &outputs, &replaced, &outputs
));
let moved = json!([{"id": 42, "output": 6, "index": 1}]);
assert!(!selection_unchanged(
&selected, &before, &outputs, &moved, &outputs
));
let mut resized = outputs.clone();
resized[0]["current_mode"]["width"] = 2560.into();
assert!(!selection_unchanged(
&selected, &before, &outputs, &before, &resized
));
assert!(
crate::restore::Restore::create("org.test.App", false, &selected, &outputs, 2)
.is_none()
);
}
#[test]
fn workspace_metadata_keeps_logical_geometry_for_a_smaller_pixel_canvas() {
let selected = sources(&json!([{"id": 42, "output": 5, "index": 1}]), &outputs()).unwrap();
let mut ready = Ready {
node: 7,
width: 1280,
height: 720,
logical_size: Some((1920, 1080)),
};
let metadata = metadata(&selected[0], &ready).unwrap();
assert_eq!(u32::try_from(&metadata["source_type"]).unwrap(), 1);
assert!(!metadata.contains_key("position"));
ready.logical_size = None;
assert!(super::metadata(&selected[0], &ready).is_err());
for name in ["window:", "workspace:no", "workspace:18446744073709551616"] {
assert_eq!(SourceKind::parse(name), SourceKind::Invalid);
}
assert_eq!(SourceKind::parse("DP-1"), SourceKind::Monitor);
}
}