Align ScreenCast picker and stream metadata with portal contracts
This commit is contained in:
1 parent
ddbed192c0
commit
09a8cd346a
5 files changed
+178
-33
No files matched your search
+2
-2
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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(());
|
||||
|
||||
+30
-10
@@ -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,11 +158,9 @@ impl cosmic::Application for Picker {
|
||||
if !self.prompt.multiple {
|
||||
self.selected.clear();
|
||||
}
|
||||
if self.selected.len() < 4 {
|
||||
self.selected.push(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Share if !self.selected.is_empty() => {
|
||||
let names: Vec<_> = self
|
||||
.selected
|
||||
|
||||
Reference in new issue
Block a user