Align ScreenCast picker and stream metadata with portal contracts

This commit is contained in:
Samuel Onoja committed 2026-09-29 23:00:42 +01:00
1 parent ddbed192c0
commit 09a8cd346a
5 files changed
+179 -34

No files matched your search

+2 -2
View File
@@ -30,7 +30,7 @@ standard file, print and application dialogs. Install both backends.
See [screen sharing](screen-sharing.md) for lifetime and recording details.
Native request dialogs use Ferese's theme and are floating windows. Screenshot,
PickColor, Wallpaper and USB consent dialogs use Wayland parent identifiers to establish
PickColor, Wallpaper, USB and ScreenCast consent dialogs use Wayland parent identifiers to establish
a transient relationship with the requesting window.
Cancelling a request or losing the portal frontend terminates its pending helper.
@@ -50,7 +50,7 @@ Matching D-Bus signatures does not establish complete feature parity.
| Settings | Native, including appearance change signals |
| Screenshot | Native screen/area capture and PickColor; true window capture remains missing |
| Wallpaper | Native desktop, lock-screen and combined targets |
| ScreenCast | Native monitors; window sources, persistence and richer metadata remain missing |
| ScreenCast | Native monitors with logical geometry metadata; window sources and persistence remain missing |
| Access, Account, AppChooser, DynamicLauncher, FileChooser, Notification, Print | Delegated to GTK |
| Background | Native application state, per-instance consent and XDG autostart |
| Usb | Native consent; standard frontend handles enumeration and device descriptors |
+3 -1
View File
@@ -26,7 +26,7 @@ may remain at the path shown in the error.
Use a Wayland screen-sharing or recording source in your application. Ferese
opens a display picker. Select a display and press **Share**. Nothing is selected
by default. Applications that request multiple displays can share up to four.
by default. Applications that request multiple displays can select multiple available displays.
The picker uses your Ferese theme colors, font, and shell corner radius. Its
background uses the same compositor material as popovers and authentication
prompts: opaque in solid mode, or blurred and translucent using the shared
@@ -71,6 +71,8 @@ configuration is preserved during installation.
- Monitor capture, one PipeWire stream per selected display, at up to 30 fps.
- Hidden or embedded pointer, as requested by the application.
- Logical stream position and size, including fractional output scales.
- Wayland parent-window relationships for the consent picker.
- Bounded frame storage and reusable shared-memory buffers.
- Cancellation during selection, an explicit stop control, and process cleanup.
- ScreenCast backend version 3, without persistent or automatic consent.
+142 -19
View File
@@ -316,7 +316,7 @@ impl Backend {
handle: OwnedObjectPath,
session_handle: OwnedObjectPath,
app_id: String,
_parent_window: String,
parent_window: String,
_options: Options,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
@@ -366,7 +366,7 @@ impl Backend {
}
let result = tokio::select! {
_ = cancel.wait() => Err(StartError::Cancelled),
result = tokio::time::timeout(Duration::from_secs(120), start_streams(if bar_controlled { "Ferese" } else { &app_id }, multiple, cursor)) => result.unwrap_or_else(|_| Err(StartError::Failed("Sharing request timed out".into()))),
result = tokio::time::timeout(Duration::from_secs(120), start_streams(if bar_controlled { "Ferese" } else { &app_id }, &parent_window, multiple, cursor)) => result.unwrap_or_else(|_| Err(StartError::Failed("Sharing request timed out".into()))),
};
let _ = connection
.object_server()
@@ -384,15 +384,6 @@ impl Backend {
supervise(children, cancel).await;
backend.end(&connection, &path).await;
});
let streams: Vec<(u32, Options)> = streams
.into_iter()
.map(|ready| {
(
ready.node,
HashMap::from([("source_type".into(), OwnedValue::from(1u32))]),
)
})
.collect();
let mut reply = Options::new();
reply.insert(
"streams".into(),
@@ -492,9 +483,10 @@ async fn picker(prompt: &Prompt) -> Result<Child, String> {
}
async fn start_streams(
app: &str,
parent: &str,
multiple: bool,
cursor: bool,
) -> Result<(Vec<Child>, Vec<Ready>), StartError> {
) -> Result<(Vec<Child>, Vec<(u32, Options)>), StartError> {
let sources = tokio::task::spawn_blocking(|| {
Capture::connect(&AtomicBool::new(false)).map(|capture| capture.sources())
})
@@ -504,18 +496,18 @@ async fn start_streams(
app: app.to_owned(),
sources,
multiple,
parent: parent.into(),
};
let selection = picker(&prompt)
.await?
.wait_with_output()
.await
.map_err(|e| e.to_string())?;
let picker = picker(&prompt).await?;
let picker_pid = picker.id().ok_or("Missing picker process ID")?;
let selection = picker.wait_with_output().await.map_err(|e| e.to_string())?;
if !selection.status.success() {
return Err(StartError::Cancelled);
}
let names: Vec<String> =
serde_json::from_slice(&selection.stdout).map_err(|_| StartError::Cancelled)?;
let selected = validate_selection(&prompt.sources, &names, multiple)?;
crate::desktop::wait_for_surface_removal(picker_pid).await?;
let mut children = Vec::new();
let mut streams = Vec::new();
for source in &selected {
@@ -545,17 +537,92 @@ async fn start_streams(
if ready.node == u32::MAX {
return Err("Invalid PipeWire node".into());
}
streams.push(ready);
streams.push((source.name.clone(), ready));
children.push(child);
}
let outputs = crate::desktop::ipc("get-outputs", serde_json::json!({})).await?;
if children
.iter_mut()
.any(|child| !matches!(child.try_wait(), Ok(None)))
{
return Err("A selected stream ended during startup".into());
}
let streams = streams
.into_iter()
.map(|(name, ready)| {
monitor_metadata(&outputs, &name, &ready).map(|metadata| (ready.node, metadata))
})
.collect::<Result<Vec<_>, _>>()?;
Ok((children, streams))
}
fn monitor_metadata(
outputs: &serde_json::Value,
name: &str,
ready: &Ready,
) -> Result<Options, String> {
let output = outputs
.as_array()
.and_then(|outputs| {
outputs
.iter()
.find(|output| output["name"].as_str() == Some(name))
})
.ok_or("Selected monitor is unavailable")?;
let integer = |key: &str| {
output[key]
.as_i64()
.and_then(|value| i32::try_from(value).ok())
.ok_or_else(|| format!("Invalid output {key}"))
};
let position = (integer("x")?, integer("y")?);
let size = (integer("width")?, integer("height")?);
if size.0 <= 0 || size.1 <= 0 || output["enabled"] != true {
return Err("Selected monitor is disabled".into());
}
let mode = &output["current_mode"];
let mut physical = (
mode["width"]
.as_u64()
.and_then(|value| u32::try_from(value).ok())
.ok_or("Invalid physical output width")?,
mode["height"]
.as_u64()
.and_then(|value| u32::try_from(value).ok())
.ok_or("Invalid physical output height")?,
);
if matches!(
output["transform"].as_str(),
Some("rotate_90" | "rotate_270" | "flipped_90" | "flipped_270")
) {
physical = (physical.1, physical.0);
}
if (ready.width, ready.height) != physical {
return Err("Selected monitor changed mode during startup".into());
}
Ok(HashMap::from([
("source_type".into(), 1u32.into()),
(
"position".into(),
Value::from(position)
.try_to_owned()
.map_err(|error| error.to_string())?,
),
(
"size".into(),
Value::from(size)
.try_to_owned()
.map_err(|error| error.to_string())?,
),
]))
}
fn validate_selection(
sources: &[Source],
names: &[String],
multiple: bool,
) -> Result<Vec<Source>, String> {
if names.is_empty() || names.len() > 4 || (!multiple && names.len() != 1) {
if names.is_empty() || names.len() > sources.len() || (!multiple && names.len() != 1) {
return Err("Invalid display selection".into());
}
let mut selected = Vec::new();
@@ -668,6 +735,62 @@ mod tests {
}
assert!(source_options(&HashMap::from([("cursor_mode".into(), 4u32.into())])).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}}]);
let metadata = monitor_metadata(
&outputs,
"display",
&Ready {
node: 1,
width: 3840,
height: 2160,
},
)
.unwrap();
assert_eq!(
<(i32, i32)>::try_from(metadata["position"].try_clone().unwrap()).unwrap(),
(-1440, 20)
);
assert_eq!(
<(i32, i32)>::try_from(metadata["size"].try_clone().unwrap()).unwrap(),
(2560, 1440)
);
assert!(
monitor_metadata(
&outputs,
"missing",
&Ready {
node: 1,
width: 3840,
height: 2160
}
)
.is_err()
);
}
#[test]
fn multiple_selection_can_include_more_than_four_displays() {
let sources = (0..6)
.map(|index| Source {
name: format!("display{index}"),
label: String::new(),
width: 100,
height: 100,
x: 0,
y: 0,
scale: 1,
})
.collect::<Vec<_>>();
let names = sources
.iter()
.map(|source| source.name.clone())
.collect::<Vec<_>>();
assert_eq!(validate_selection(&sources, &names, true).unwrap().len(), 6);
assert!(validate_selection(&sources, &names, false).is_err());
}
#[test]
fn rejects_empty_unknown_duplicate_and_excess_selections() {
let source = Source {
+1 -1
View File
@@ -301,7 +301,7 @@ async fn select_geometry(point: bool, windows: Option<String>) -> Result<Option<
Ok(Some(geometry))
}
async fn wait_for_surface_removal(pid: u32) -> Result<(), String> {
pub(crate) async fn wait_for_surface_removal(pid: u32) -> Result<(), String> {
for _ in 0..100 {
if ipc("has-client-surfaces", serde_json::json!({"pid": pid})).await? == false {
return Ok(());
+31 -11
View File
@@ -14,12 +14,22 @@ pub struct Prompt {
pub app: String,
pub sources: Vec<Source>,
pub multiple: bool,
#[serde(default)]
pub parent: String,
}
#[derive(Clone, Debug)]
enum Message {
WindowOpened(cosmic::iced::window::Id),
MaterialAttached(Result<ferese_theme::material::ModalMaterial, String>),
Attached(
Result<
(
Option<std::sync::Arc<crate::parent::Parent>>,
Result<ferese_theme::material::ModalMaterial, String>,
),
String,
>,
),
Select(usize),
Share,
Cancel,
@@ -28,6 +38,7 @@ enum Message {
struct Picker {
core: Core,
material: Option<ferese_theme::material::ModalMaterial>,
parent: Option<std::sync::Arc<crate::parent::Parent>>,
prompt: Prompt,
selected: Vec<usize>,
background: cosmic::iced::Color,
@@ -93,6 +104,7 @@ impl cosmic::Application for Picker {
let mut app = Self {
core,
material: None,
parent: None,
prompt,
selected: Vec::new(),
background,
@@ -120,14 +132,24 @@ impl cosmic::Application for Picker {
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::WindowOpened(id) => {
return cosmic::iced::window::run(
id,
ferese_theme::material::ModalMaterial::attach,
)
.map(|result| cosmic::Action::App(Message::MaterialAttached(result)));
let parent = self.prompt.parent.clone();
return cosmic::iced::window::run(id, move |window| {
let parent =
crate::parent::Parent::attach(window, &parent)?.map(std::sync::Arc::new);
Ok((
parent,
ferese_theme::material::ModalMaterial::attach(window),
))
})
.map(|result| cosmic::Action::App(Message::Attached(result)));
}
Message::MaterialAttached(result) => {
self.material = result.ok();
Message::Attached(Ok((parent, material))) => {
self.parent = parent;
self.material = material.ok();
}
Message::Attached(Err(error)) => {
eprintln!("ferese portal: {error}");
return cosmic::iced::exit();
}
Message::Select(index) if index < self.prompt.sources.len() => {
if self.selected.contains(&index) {
@@ -136,9 +158,7 @@ impl cosmic::Application for Picker {
if !self.prompt.multiple {
self.selected.clear();
}
if self.selected.len() < 4 {
self.selected.push(index);
}
self.selected.push(index);
}
}
Message::Share if !self.selected.is_empty() => {