Add native ScreenCast portal with PipeWire monitor streams

This commit is contained in:
Samuel Onoja committed 2026-09-28 07:54:22 +01:00
1 parent cb512eece9
commit e7d92280d6
21 files changed
+2228 -15

No files matched your search

+597
View File
@@ -0,0 +1,597 @@
//! ScreenCast portal backend. Only the portal frontend may create sessions;
//! consent never survives a session, a process restart, or a capture failure.
use crate::{
capture::{Capture, Source},
picker::Prompt,
stream::Ready,
};
use std::{
collections::HashMap,
process::Stdio,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
process::{Child, Command},
sync::{Mutex, Notify},
};
use zbus::{
Connection,
message::Header,
object_server::SignalEmitter,
zvariant::{OwnedObjectPath, OwnedValue, Value},
};
type Options = HashMap<String, OwnedValue>;
type Reply = (u32, Options);
#[derive(Debug)]
enum StartError {
Cancelled,
Failed(String),
}
impl From<String> for StartError {
fn from(message: String) -> Self {
Self::Failed(message)
}
}
impl From<&str> for StartError {
fn from(message: &str) -> Self {
Self::Failed(message.into())
}
}
const FRONTEND: &str = "org.freedesktop.portal.Desktop";
const PATH: &str = "/org/freedesktop/portal/desktop";
#[derive(Default)]
struct Cancel {
stopped: AtomicBool,
changed: Notify,
}
impl Cancel {
fn stop(&self) {
self.stopped.store(true, Ordering::SeqCst);
self.changed.notify_one();
}
async fn wait(&self) {
if !self.stopped.load(Ordering::SeqCst) {
self.changed.notified().await;
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Phase {
Created,
Selected,
Starting,
Running,
}
struct Session {
owner: String,
app: String,
phase: Phase,
multiple: bool,
cursor: bool,
cancel: Arc<Cancel>,
}
#[derive(Clone, Default)]
pub struct Backend {
sessions: Arc<Mutex<HashMap<String, Session>>>,
}
async fn authorize(connection: &Connection, header: &Header<'_>) -> zbus::fdo::Result<String> {
let sender = header
.sender()
.ok_or_else(|| zbus::fdo::Error::AccessDenied("Missing caller".into()))?;
let dbus = zbus::fdo::DBusProxy::new(connection).await?;
let owner = dbus.get_name_owner(FRONTEND.try_into().unwrap()).await?;
if sender.as_str() != owner.as_str() {
return Err(zbus::fdo::Error::AccessDenied(
"Use the ScreenCast desktop portal".into(),
));
}
Ok(sender.to_string())
}
fn error(message: &str) -> zbus::fdo::Error {
zbus::fdo::Error::InvalidArgs(message.into())
}
fn source_options(options: &Options) -> zbus::fdo::Result<(bool, bool)> {
let number = |key: &str, default| {
options
.get(key)
.map(u32::try_from)
.transpose()
.map(|v| v.unwrap_or(default))
.map_err(|_| error("Invalid option type"))
};
let types = number("types", 1)?;
let cursor = number("cursor_mode", 1)?;
if types == 0 || types & !1 != 0 {
return Err(error("Only monitor sharing is supported"));
}
if cursor != 1 && cursor != 2 {
return Err(error("Unsupported cursor mode"));
}
let multiple = options
.get("multiple")
.map(bool::try_from)
.transpose()
.map_err(|_| error("Invalid multiple option"))?
.unwrap_or(false);
Ok((multiple, cursor == 2))
}
impl Backend {
async fn end(&self, connection: &Connection, path: &str) {
let session = self.sessions.lock().await.remove(path);
if let Some(session) = session {
session.cancel.stop();
if let Ok(emitter) = SignalEmitter::new(connection, path) {
let _ = SessionObject::closed(&emitter).await;
}
// A Close method may still hold the interface read lock.
let conn = connection.clone();
let path = path.to_owned();
tokio::spawn(async move {
let _ = conn
.object_server()
.remove::<SessionObject, _>(path.as_str())
.await;
});
}
}
}
#[zbus::interface(name = "org.freedesktop.impl.portal.ScreenCast")]
impl Backend {
#[zbus(property, name = "version")]
fn version(&self) -> u32 {
3
}
#[zbus(property)]
fn available_source_types(&self) -> u32 {
1
}
#[zbus(property)]
fn available_cursor_modes(&self) -> u32 {
3
}
async fn create_session(
&self,
_handle: OwnedObjectPath,
session_handle: OwnedObjectPath,
app_id: String,
_options: Options,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<Reply> {
let owner = authorize(connection, &header).await?;
if !session_handle
.as_str()
.starts_with("/org/freedesktop/portal/desktop/session/")
|| app_id.len() > 512
{
return Err(error("Invalid session"));
}
let mut sessions = self.sessions.lock().await;
if sessions.len() >= 8 || sessions.contains_key(session_handle.as_str()) {
return Err(zbus::fdo::Error::LimitsExceeded(
"Session limit reached or duplicate session".into(),
));
}
let inserted = connection
.object_server()
.at(
session_handle.clone(),
SessionObject {
backend: self.clone(),
path: session_handle.to_string(),
owner: owner.clone(),
},
)
.await?;
if !inserted {
return Err(error("Session object already exists"));
}
sessions.insert(
session_handle.to_string(),
Session {
owner,
app: app_id,
phase: Phase::Created,
multiple: false,
cursor: false,
cancel: Arc::default(),
},
);
Ok((0, Options::new()))
}
async fn select_sources(
&self,
_handle: OwnedObjectPath,
session_handle: OwnedObjectPath,
app_id: String,
options: Options,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<Reply> {
let owner = authorize(connection, &header).await?;
let settings = source_options(&options);
let mut sessions = self.sessions.lock().await;
let session = sessions
.get_mut(session_handle.as_str())
.ok_or_else(|| error("Unknown session"))?;
if session.owner != owner
|| session.app != app_id
|| !matches!(session.phase, Phase::Created | Phase::Selected)
{
return Err(error("Invalid session state"));
}
match settings {
Ok((multiple, cursor)) => {
session.multiple = multiple;
session.cursor = cursor;
session.phase = Phase::Selected;
Ok((0, Options::new()))
}
Err(e) => {
drop(sessions);
self.end(connection, session_handle.as_str()).await;
Err(e)
}
}
}
#[allow(clippy::too_many_arguments)] // ScreenCast D-Bus method signature.
async fn start(
&self,
handle: OwnedObjectPath,
session_handle: OwnedObjectPath,
app_id: String,
_parent_window: String,
_options: Options,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<Reply> {
let owner = authorize(connection, &header).await?;
if !handle
.as_str()
.starts_with("/org/freedesktop/portal/desktop/request/")
{
return Err(error("Invalid request path"));
}
let (multiple, cursor, cancel) = {
let mut sessions = self.sessions.lock().await;
let session = sessions
.get_mut(session_handle.as_str())
.ok_or_else(|| error("Unknown session"))?;
if session.owner != owner || session.app != app_id || session.phase != Phase::Selected {
return Err(error("Select sources before starting a session"));
}
session.phase = Phase::Starting;
(session.multiple, session.cursor, session.cancel.clone())
};
let inserted = connection
.object_server()
.at(
handle.clone(),
Request {
cancel: cancel.clone(),
owner,
},
)
.await;
match inserted {
Ok(true) => (),
result => {
self.end(connection, session_handle.as_str()).await;
return Err(match result {
Err(e) => e.into(),
_ => error("Request object already exists"),
});
}
}
let result = tokio::select! {
_ = cancel.wait() => Err(StartError::Cancelled),
result = tokio::time::timeout(Duration::from_secs(120), start_streams(&app_id, multiple, cursor)) => result.unwrap_or_else(|_| Err(StartError::Failed("Sharing request timed out".into()))),
};
let _ = connection
.object_server()
.remove::<Request, _>(handle)
.await;
match result {
Ok((children, indicator, streams)) if !cancel.stopped.load(Ordering::SeqCst) => {
if let Some(session) = self.sessions.lock().await.get_mut(session_handle.as_str()) {
session.phase = Phase::Running;
}
let backend = self.clone();
let connection = connection.clone();
let path = session_handle.to_string();
tokio::spawn(async move {
supervise(children, indicator, 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(),
OwnedValue::try_from(Value::from(streams))
.map_err(|e| error(&e.to_string()))?,
);
Ok((0, reply))
}
result => {
let code = match result {
Err(StartError::Failed(message)) => {
eprintln!("ferese portal: {message}");
2
}
_ => 1,
};
self.end(connection, session_handle.as_str()).await;
Ok((code, Options::new()))
}
}
}
}
struct Request {
cancel: Arc<Cancel>,
owner: String,
}
#[zbus::interface(name = "org.freedesktop.impl.portal.Request")]
impl Request {
fn close(&self, #[zbus(header)] header: Header<'_>) -> zbus::fdo::Result<()> {
if header.sender().map(|s| s.as_str()) != Some(self.owner.as_str()) {
return Err(zbus::fdo::Error::AccessDenied("Wrong request owner".into()));
}
self.cancel.stop();
Ok(())
}
}
struct SessionObject {
backend: Backend,
path: String,
owner: String,
}
#[zbus::interface(name = "org.freedesktop.impl.portal.Session")]
impl SessionObject {
async fn close(
&self,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: Header<'_>,
) -> zbus::fdo::Result<()> {
if header.sender().map(|s| s.as_str()) != Some(self.owner.as_str()) {
return Err(zbus::fdo::Error::AccessDenied("Wrong session owner".into()));
}
self.backend.end(connection, &self.path).await;
Ok(())
}
#[zbus(signal)]
async fn closed(emitter: &SignalEmitter<'_>) -> zbus::Result<()>;
}
// All helpers die with the service, including a picker awaiting user input.
fn child_command() -> Result<Command, String> {
let mut command = Command::new(std::env::current_exe().map_err(|e| e.to_string())?);
let parent = std::process::id() as libc::pid_t;
// SAFETY: only async-signal-safe libc calls run between fork and exec.
unsafe {
command.pre_exec(move || {
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) != 0 {
return Err(std::io::Error::last_os_error());
}
if libc::getppid() != parent {
libc::_exit(1);
}
Ok(())
});
}
command.kill_on_drop(true);
Ok(command)
}
async fn picker(prompt: &Prompt) -> Result<Child, String> {
let mut child = child_command()?
.arg("--picker")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.kill_on_drop(true)
.spawn()
.map_err(|e| e.to_string())?;
let mut input = child.stdin.take().unwrap();
input
.write_all(&serde_json::to_vec(prompt).unwrap())
.await
.map_err(|e| e.to_string())?;
drop(input);
Ok(child)
}
async fn start_streams(
app: &str,
multiple: bool,
cursor: bool,
) -> Result<(Vec<Child>, Child, Vec<Ready>), StartError> {
let sources = tokio::task::spawn_blocking(|| {
Capture::connect(&AtomicBool::new(false)).map(|capture| capture.sources())
})
.await
.map_err(|e| e.to_string())??;
let prompt = Prompt {
app: app.to_owned(),
sources,
multiple,
indicator: false,
};
let selection = picker(&prompt)
.await?
.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)?;
let mut children = Vec::new();
let mut streams = Vec::new();
for source in &selected {
let mut child = child_command()?
.args([
"--stream",
&source.name,
if cursor { "embedded" } else { "hidden" },
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.kill_on_drop(true)
.spawn()
.map_err(|e| e.to_string())?;
let mut line = String::new();
let output = child.stdout.take().unwrap();
tokio::time::timeout(
Duration::from_secs(10),
BufReader::new(output).read_line(&mut line),
)
.await
.map_err(|_| "PipeWire stream startup timed out")?
.map_err(|e| e.to_string())?;
let ready: Ready =
serde_json::from_str(&line).map_err(|_| "PipeWire stream could not start")?;
if ready.node == u32::MAX {
return Err("Invalid PipeWire node".into());
}
streams.push(ready);
children.push(child);
}
let indicator = picker(&Prompt {
app: app.to_owned(),
sources: selected,
multiple,
indicator: true,
})
.await?;
Ok((children, indicator, streams))
}
fn validate_selection(
sources: &[Source],
names: &[String],
multiple: bool,
) -> Result<Vec<Source>, String> {
if names.is_empty() || names.len() > 4 || (!multiple && names.len() != 1) {
return Err("Invalid display selection".into());
}
let mut selected = Vec::new();
for name in names {
if selected.iter().any(|s: &Source| &s.name == name) {
return Err("Duplicate display selection".into());
}
selected.push(
sources
.iter()
.find(|s| &s.name == name)
.ok_or("Unknown display selection")?
.clone(),
);
}
Ok(selected)
}
async fn supervise(mut children: Vec<Child>, mut indicator: Child, cancel: Arc<Cancel>) {
loop {
if cancel.stopped.load(Ordering::SeqCst)
|| !matches!(indicator.try_wait(), Ok(None))
|| children
.iter_mut()
.any(|c| !matches!(c.try_wait(), Ok(None)))
{
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
cancel.stop();
for child in &mut children {
let _ = child.kill().await;
}
let _ = indicator.kill().await;
}
pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
let backend = Backend::default();
let connection = zbus::connection::Builder::session()?
.name("org.freedesktop.impl.portal.desktop.ferese")?
.serve_at(PATH, backend.clone())?
.build()
.await?;
// Frontend death must revoke every stream, even if Session.Close never arrives.
loop {
tokio::time::sleep(Duration::from_secs(1)).await;
if backend.sessions.lock().await.is_empty() {
continue;
}
let owner = match zbus::fdo::DBusProxy::new(&connection).await {
Ok(proxy) => proxy
.get_name_owner(FRONTEND.try_into().unwrap())
.await
.ok()
.map(|name| name.to_string()),
Err(_) => None,
};
let stale: Vec<_> = backend
.sessions
.lock()
.await
.iter()
.filter(|(_, session)| owner.as_deref() != Some(session.owner.as_str()))
.map(|(path, _)| path.clone())
.collect();
for path in stale {
backend.end(&connection, &path).await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_unimplemented_sources_and_cursor_modes() {
assert_eq!(source_options(&Options::new()).unwrap(), (false, false));
for bits in [0u32, 2, 3, 4] {
assert!(source_options(&HashMap::from([("types".into(), bits.into())])).is_err());
}
assert!(source_options(&HashMap::from([("cursor_mode".into(), 4u32.into())])).is_err());
}
#[test]
fn rejects_empty_unknown_duplicate_and_excess_selections() {
let source = Source {
name: "test".into(),
label: "Test".into(),
width: 640,
height: 480,
x: 0,
y: 0,
scale: 1,
};
assert!(validate_selection(std::slice::from_ref(&source), &[], false).is_err());
assert!(
validate_selection(std::slice::from_ref(&source), &["other".into()], false).is_err()
);
assert!(
validate_selection(
std::slice::from_ref(&source),
&["test".into(), "test".into()],
true
)
.is_err()
);
assert_eq!(
validate_selection(&[source], &["test".into()], false)
.unwrap()
.len(),
1
);
}
}
+425
View File
@@ -0,0 +1,425 @@
//! Bounded, cancellable Wayland capture. No screenshots or external capture tools.
use memmap2::MmapMut;
use serde::{Deserialize, Serialize};
use std::{
fs::File,
os::fd::{AsFd, AsRawFd, FromRawFd},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
};
use wayland_client::{
Connection, Dispatch, EventQueue, QueueHandle, WEnum,
protocol::{wl_buffer, wl_output, wl_registry, wl_shm, wl_shm_pool},
};
use wayland_protocols_wlr::screencopy::v1::client::{
zwlr_screencopy_frame_v1 as frame, zwlr_screencopy_manager_v1 as manager,
};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Source {
pub name: String,
pub label: String,
pub width: i32,
pub height: i32,
pub x: i32,
pub y: i32,
pub scale: i32,
}
struct Output {
global: u32,
proxy: wl_output::WlOutput,
source: Source,
}
pub struct Frame {
pub width: u32,
pub height: u32,
pub stride: u32,
pub pixels: Vec<u8>,
}
struct Buffer {
_file: File,
map: MmapMut,
proxy: wl_buffer::WlBuffer,
width: u32,
height: u32,
stride: u32,
format: wl_shm::Format,
}
impl Drop for Buffer {
fn drop(&mut self) {
self.proxy.destroy();
}
}
#[derive(Default)]
struct State {
outputs: Vec<Output>,
manager: Option<manager::ZwlrScreencopyManagerV1>,
shm: Option<wl_shm::WlShm>,
buffer: Option<Buffer>,
result: Option<Result<(), String>>,
flipped: bool,
}
pub struct Capture {
connection: Connection,
queue: EventQueue<State>,
state: State,
}
impl Capture {
pub fn connect(stop: &AtomicBool) -> Result<Self, String> {
let connection = Connection::connect_to_env().map_err(|e| e.to_string())?;
let queue = connection.new_event_queue();
connection.display().get_registry(&queue.handle(), ());
let mut this = Self {
connection,
queue,
state: State::default(),
};
// Two syncs deliver registry bindings and output metadata without an
// uninterruptible roundtrip on a stalled compositor.
this.sync(stop)?;
this.sync(stop)?;
if this.state.manager.is_none() || this.state.shm.is_none() {
return Err("Ferese screen capture is unavailable in this session".into());
}
Ok(this)
}
fn sync(&mut self, stop: &AtomicBool) -> Result<(), String> {
let done = Arc::new(AtomicBool::new(false));
self.connection
.display()
.sync(&self.queue.handle(), done.clone());
let deadline = Instant::now() + Duration::from_secs(5);
while !done.load(Ordering::Relaxed) {
self.dispatch(stop, deadline)?;
}
Ok(())
}
pub fn sources(&self) -> Vec<Source> {
self.state
.outputs
.iter()
.map(|o| o.source.clone())
.collect()
}
pub fn frame(
&mut self,
name: &str,
cursor: bool,
stop: &AtomicBool,
mut pixels: Vec<u8>,
) -> Result<Frame, String> {
let output = self
.state
.outputs
.iter()
.find(|o| o.source.name == name)
.ok_or("Shared monitor disconnected")?;
self.state.result = None;
self.state.flipped = false;
let frame = self.state.manager.as_ref().unwrap().capture_output(
cursor as i32,
&output.proxy,
&self.queue.handle(),
(),
);
let deadline = Instant::now() + Duration::from_secs(5);
while self.state.result.is_none() {
if let Err(error) = self.dispatch(stop, deadline) {
frame.destroy();
return Err(error);
}
}
frame.destroy();
self.state.result.take().unwrap()?;
let buffer = self
.state
.buffer
.as_ref()
.ok_or("Capture supplied no buffer")?;
pixels.resize(buffer.map.len(), 0);
pixels.copy_from_slice(&buffer.map);
if self.state.flipped {
let stride = buffer.stride as usize;
for y in 0..buffer.height as usize / 2 {
let bottom = (buffer.height as usize - y - 1) * stride;
let (top, tail) = pixels.split_at_mut(bottom);
top[y * stride..(y + 1) * stride].swap_with_slice(&mut tail[..stride]);
}
}
Ok(Frame {
width: buffer.width,
height: buffer.height,
stride: buffer.stride,
pixels,
})
}
fn dispatch(&mut self, stop: &AtomicBool, deadline: Instant) -> Result<(), String> {
if stop.load(Ordering::Relaxed) {
return Err("Capture cancelled".into());
}
if Instant::now() >= deadline {
return Err("Compositor capture timed out".into());
}
self.queue
.dispatch_pending(&mut self.state)
.map_err(|e| e.to_string())?;
self.connection.flush().map_err(|e| e.to_string())?;
if let Some(read) = self.queue.prepare_read() {
let mut fd = libc::pollfd {
fd: read.connection_fd().as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
// SAFETY: a single valid pollfd, bounded timeout; Wayland owns the FD.
let ready = unsafe { libc::poll(&mut fd, 1, 20) };
if ready > 0 {
read.read().map_err(|e| e.to_string())?;
} else if ready < 0
&& std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted
{
return Err(std::io::Error::last_os_error().to_string());
}
}
self.queue
.dispatch_pending(&mut self.state)
.map_err(|e| e.to_string())?;
Ok(())
}
}
impl Dispatch<wl_registry::WlRegistry, ()> for State {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
match event {
wl_registry::Event::Global {
name,
interface,
version,
} => match interface.as_str() {
"wl_output" => state.outputs.push(Output {
global: name,
proxy: registry.bind(name, version.min(4), qh, name),
source: Source {
name: format!("output-{name}"),
label: "Display".into(),
width: 0,
height: 0,
x: 0,
y: 0,
scale: 1,
},
}),
"wl_shm" => state.shm = Some(registry.bind(name, 1, qh, ())),
"zwlr_screencopy_manager_v1" => {
state.manager = Some(registry.bind(name, version.min(3), qh, ()))
}
_ => (),
},
wl_registry::Event::GlobalRemove { name } => state.outputs.retain(|o| o.global != name),
_ => (),
}
}
}
impl Dispatch<wl_output::WlOutput, u32> for State {
fn event(
state: &mut Self,
_: &wl_output::WlOutput,
event: wl_output::Event,
name: &u32,
_: &Connection,
_: &QueueHandle<Self>,
) {
let Some(output) = state.outputs.iter_mut().find(|o| o.global == *name) else {
return;
};
match event {
wl_output::Event::Name { name } => output.source.name = name,
wl_output::Event::Description { description } => output.source.label = description,
wl_output::Event::Geometry { x, y, .. } => {
output.source.x = x;
output.source.y = y;
}
wl_output::Event::Scale { factor } => output.source.scale = factor.max(1),
wl_output::Event::Mode {
flags: WEnum::Value(flags),
width,
height,
..
} if flags.contains(wl_output::Mode::Current) => {
output.source.width = width;
output.source.height = height;
}
_ => (),
}
}
}
impl Dispatch<frame::ZwlrScreencopyFrameV1, ()> for State {
fn event(
state: &mut Self,
frame: &frame::ZwlrScreencopyFrameV1,
event: frame::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
match event {
frame::Event::Buffer {
format: WEnum::Value(format),
width,
height,
stride,
} => {
let result = (|| {
if format != wl_shm::Format::Argb8888 && format != wl_shm::Format::Xrgb8888 {
return Err("Unsupported capture pixel format".into());
}
let bytes = buffer_size(width, height, stride)?;
if let Some(buffer) = &state.buffer
&& (buffer.width, buffer.height, buffer.stride, buffer.format)
== (width, height, stride, format)
{
frame.copy(&buffer.proxy);
return Ok(());
}
// SAFETY: fixed NUL-terminated name and a newly owned memfd.
let fd = unsafe {
libc::memfd_create(c"ferese-screencast".as_ptr(), libc::MFD_CLOEXEC)
};
if fd < 0 {
return Err(std::io::Error::last_os_error().to_string());
}
let file = unsafe { File::from_raw_fd(fd) };
file.set_len(bytes as u64).map_err(|e| e.to_string())?;
// SAFETY: the file has the checked mapping length and lives with the mapping.
let map = unsafe { MmapMut::map_mut(&file) }.map_err(|e| e.to_string())?;
let pool = state
.shm
.as_ref()
.ok_or("Missing shared memory global")?
.create_pool(file.as_fd(), bytes as i32, qh, ());
let proxy = pool.create_buffer(
0,
width as i32,
height as i32,
stride as i32,
format,
qh,
(),
);
pool.destroy();
frame.copy(&proxy);
state.buffer = Some(Buffer {
_file: file,
map,
proxy,
width,
height,
stride,
format,
});
Ok::<(), String>(())
})();
if let Err(e) = result {
state.result = Some(Err(e));
}
}
frame::Event::Ready { .. } => state.result = Some(Ok(())),
frame::Event::Failed => {
state.result = Some(Err(
"Capture ended: session locked, monitor removed, or compositor unavailable"
.into(),
))
}
frame::Event::Flags {
flags: WEnum::Value(flags),
} => state.flipped = flags.contains(frame::Flags::YInvert),
_ => (),
}
}
}
fn buffer_size(width: u32, height: u32, stride: u32) -> Result<usize, String> {
let row = width.checked_mul(4).ok_or("Capture dimensions overflow")?;
let bytes = stride
.checked_mul(height)
.ok_or("Capture dimensions overflow")?;
if width == 0 || height == 0 || stride < row || bytes > 128 * 1024 * 1024 {
return Err("Unsupported capture dimensions".into());
}
Ok(bytes as usize)
}
impl Dispatch<wayland_client::protocol::wl_callback::WlCallback, Arc<AtomicBool>> for State {
fn event(
_: &mut Self,
_: &wayland_client::protocol::wl_callback::WlCallback,
_: wayland_client::protocol::wl_callback::Event,
done: &Arc<AtomicBool>,
_: &Connection,
_: &QueueHandle<Self>,
) {
done.store(true, Ordering::Relaxed);
}
}
wayland_client::delegate_noop!(State: ignore wl_shm::WlShm);
wayland_client::delegate_noop!(State: ignore wl_shm_pool::WlShmPool);
wayland_client::delegate_noop!(State: ignore wl_buffer::WlBuffer);
wayland_client::delegate_noop!(State: ignore manager::ZwlrScreencopyManagerV1);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_unbounded_and_invalid_buffers() {
assert_eq!(buffer_size(640, 480, 2560).unwrap(), 1228800);
assert!(buffer_size(640, 480, 12).is_err());
assert!(buffer_size(0, 480, 0).is_err());
assert!(buffer_size(u32::MAX, 2, u32::MAX).is_err());
assert!(buffer_size(20000, 20000, 80000).is_err());
}
}
+22
View File
@@ -0,0 +1,22 @@
mod backend;
mod capture;
mod picker;
mod stream;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().skip(1).collect();
match args.as_slice() {
[flag, name, cursor] if flag == "--stream" => {
stream::run(name.clone(), cursor == "embedded")
}
[flag] if flag == "--sources" => {
let capture = capture::Capture::connect(&std::sync::atomic::AtomicBool::new(false))?;
println!("{}", serde_json::to_string(&capture.sources())?);
Ok(())
}
[flag] if flag == "--picker" => picker::run(),
[] => tokio::runtime::Runtime::new()?.block_on(backend::run()),
_ => Err("Usage: xdg-desktop-portal-ferese [--sources]".into()),
}
}
+264
View File
@@ -0,0 +1,264 @@
use crate::capture::Source;
use cosmic::{
ApplicationExt, Element,
app::{Core, Settings, Task},
iced::Length,
widget::{button, column, container, row, text},
};
use serde::{Deserialize, Serialize};
use std::io::{Read, Write};
#[derive(Clone, Serialize, Deserialize)]
pub struct Prompt {
pub app: String,
pub sources: Vec<Source>,
pub multiple: bool,
pub indicator: bool,
}
#[derive(Clone, Debug)]
enum Message {
Select(usize),
Share,
Cancel,
}
struct Picker {
core: Core,
prompt: Prompt,
selected: Vec<usize>,
}
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
let mut input = String::new();
std::io::stdin()
.take(64 * 1024)
.read_to_string(&mut input)?;
let prompt: Prompt = serde_json::from_str(&input)?;
if prompt.sources.is_empty() || prompt.sources.len() > 16 {
return Err("No shareable displays".into());
}
let (theme, font) = appearance();
cosmic::app::run::<Picker>(
Settings::default()
.size(cosmic::iced::Size::new(480., 440.))
.theme(theme)
.default_font(font)
.default_text_size(14.)
.is_daemon(false),
prompt,
)?;
Ok(())
}
impl cosmic::Application for Picker {
type Executor = cosmic::executor::Default;
type Flags = Prompt;
type Message = Message;
const APP_ID: &'static str = "dev.ferese.ScreenShare";
fn core(&self) -> &Core {
&self.core
}
fn core_mut(&mut self) -> &mut Core {
&mut self.core
}
fn init(core: Core, prompt: Prompt) -> (Self, Task<Message>) {
// No preselected source: clicking Share must be a deliberate choice.
let mut app = Self {
core,
prompt,
selected: Vec::new(),
};
let title = if app.prompt.indicator {
"Ferese — screen sharing"
} else {
"Ferese — share your screen"
};
let task = app
.core
.main_window_id()
.map(|id| app.set_window_title(title.into(), id))
.unwrap_or_else(Task::none);
(app, task)
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Select(index) if index < self.prompt.sources.len() => {
if self.selected.contains(&index) {
self.selected.retain(|i| *i != index);
} else {
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
.iter()
.map(|i| &self.prompt.sources[*i].name)
.collect();
println!("{}", serde_json::to_string(&names).unwrap());
let _ = std::io::stdout().flush();
return cosmic::iced::exit();
}
Message::Cancel => return cosmic::iced::exit(),
_ => (),
}
Task::none()
}
fn view(&self) -> Element<'_, Message> {
let app = if self.prompt.app.is_empty() {
"An application"
} else {
&self.prompt.app
};
let mut content = column([])
.spacing(16)
.push(
text(if self.prompt.indicator {
"Your screen is being shared"
} else {
"Share your screen"
})
.size(24),
)
.push(text(if self.prompt.indicator {
format!("{app} can see the displays below.")
} else {
format!("{app} wants to see your screen. Choose what to share.")
}));
for (index, source) in self.prompt.sources.iter().enumerate() {
let label = format!(
"{}{} — {} × {}",
if self.selected.contains(&index) {
"✓ "
} else {
""
},
source.name,
source.width,
source.height
);
if self.prompt.indicator {
content = content.push(text(label));
} else {
content = content.push(
button::text(label)
.width(Length::Fill)
.on_press(Message::Select(index)),
);
}
}
content = content.push(text(
"Everything visible on a shared display, including notifications, may be seen.",
));
if self.prompt.indicator {
content = content.push(button::destructive("Stop sharing").on_press(Message::Cancel));
} else {
let mut share = button::suggested("Share");
if !self.selected.is_empty() {
share = share.on_press(Message::Share);
}
content = content.push(
row([])
.spacing(12)
.push(button::text("Cancel").on_press(Message::Cancel))
.push(share),
);
}
container(content).padding(24).width(Length::Fill).into()
}
}
fn appearance() -> (cosmic::Theme, cosmic::font::Font) {
let document = ferese_config::config_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| ferese_config::Document::parse(&s).ok());
let string = |key: &str, fallback: &str| {
document
.as_ref()
.and_then(|d| d.get(key))
.and_then(|v| v.as_str())
.unwrap_or(fallback)
.to_owned()
};
let rgba = |key, fallback| {
let s = string(key, fallback);
let parse = |s: &str| {
let hex = s.strip_prefix('#')?;
let n = u32::from_str_radix(hex, 16).ok()?;
Some(match hex.len() {
6 => (n << 8) | 255,
8 => n,
_ => return None,
})
};
let n = parse(&s).unwrap_or_else(|| parse(fallback).unwrap());
cosmic::cosmic_theme::palette::Srgba::new(
((n >> 24) & 255) as f32 / 255.,
((n >> 16) & 255) as f32 / 255.,
((n >> 8) & 255) as f32 / 255.,
(n & 255) as f32 / 255.,
)
};
let background = rgba("theme.colors.surface_base", "#111821");
let foreground = rgba("theme.colors.text_primary", "#F4F7FB");
let accent = rgba("theme.colors.accent", "#3D7BE6");
let radius = document
.as_ref()
.and_then(|d| d.get("theme.geometry.shell_radius"))
.and_then(|v| v.as_f64())
.unwrap_or(14.)
.clamp(0., 40.) as f32;
let corners = cosmic::cosmic_theme::CornerRadii {
radius_xs: [radius.min(4.); 4],
radius_s: [radius.min(8.); 4],
radius_m: [radius; 4],
radius_l: [radius; 4],
radius_xl: [radius; 4],
..Default::default()
};
let builder = if background.red + background.green + background.blue > 1.8 {
cosmic::cosmic_theme::ThemeBuilder::light()
} else {
cosmic::cosmic_theme::ThemeBuilder::dark()
};
let theme = cosmic::Theme::custom(std::sync::Arc::new(
builder
.bg_color(background)
.primary_container_bg(background)
.text_tint(foreground.color)
.accent(accent.color)
.corner_radii(corners)
.build(),
));
let family: &'static str =
Box::leak(string("theme.typography.font_family", "Inter").into_boxed_str());
(
theme,
cosmic::font::Font {
family: cosmic::iced::font::Family::Name(family),
..cosmic::font::default()
},
)
}
+289
View File
@@ -0,0 +1,289 @@
//! PipeWire runs outside the compositor. Keep one frame, drop old frames under
//! backpressure, and destroy the node when the portal's control pipe closes.
use crate::capture::{Capture, Frame};
use pipewire::{self as pw, properties::properties, spa};
use serde::{Deserialize, Serialize};
use spa::pod::Pod;
use std::{
io::{Read, Write},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
};
#[derive(Debug, Serialize, Deserialize)]
pub struct Ready {
pub node: u32,
pub width: u32,
pub height: u32,
}
struct Data {
latest: Arc<Mutex<Frame>>,
stop: Arc<AtomicBool>,
streaming: Arc<AtomicBool>,
fresh: Arc<AtomicBool>,
announced: bool,
}
fn int_property(key: u32, value: i32) -> spa::pod::Property {
spa::pod::Property {
key,
flags: spa::pod::PropertyFlags::empty(),
value: spa::pod::Value::Int(value),
}
}
fn pod(object: spa::pod::Object) -> Vec<u8> {
spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&spa::pod::Value::Object(object),
)
.unwrap()
.0
.into_inner()
}
pub fn run(name: String, cursor: bool) -> Result<(), Box<dyn std::error::Error>> {
let stop = Arc::new(AtomicBool::new(false));
let pipe_stop = stop.clone();
std::thread::spawn(move || {
let mut byte = [0u8];
let _ = std::io::stdin().read(&mut byte);
pipe_stop.store(true, Ordering::Relaxed);
});
let mut capture = Capture::connect(&stop)?;
let first = capture.frame(&name, cursor, &stop, Vec::new())?;
let (width, height) = (first.width, first.height);
let latest = Arc::new(Mutex::new(first));
let streaming = Arc::new(AtomicBool::new(false));
let fresh = Arc::new(AtomicBool::new(false));
pw::init();
let mainloop = pw::main_loop::MainLoopRc::new(None)?;
let context = pw::context::ContextRc::new(&mainloop, None)?;
let core = context.connect_rc(None)?;
let stream = pw::stream::StreamBox::new(
&core,
"Ferese ScreenCast",
properties! {
"media.class" => "Video/Source",
"media.type" => "Video",
"media.category" => "Capture",
"media.role" => "Screen",
"node.name" => format!("ferese.screencast.{}", std::process::id()),
"node.description" => format!("Ferese — {name}"),
"node.virtual" => "true",
"node.want-driver" => "true",
"node.always-process" => "false",
"node.pause-on-idle" => "true",
"node.latency" => "1/30",
},
)?;
let data = Data {
latest: latest.clone(),
stop: stop.clone(),
streaming: streaming.clone(),
fresh: fresh.clone(),
announced: false,
};
let _listener = stream
.add_local_listener_with_user_data(data)
.state_changed(move |stream, data, _, state| {
data.fresh.store(false, Ordering::SeqCst);
data.streaming.store(
matches!(state, pw::stream::StreamState::Streaming),
Ordering::Relaxed,
);
match state {
pw::stream::StreamState::Paused | pw::stream::StreamState::Streaming
if !data.announced =>
{
let node = stream.node_id();
if node != u32::MAX {
data.announced = true;
println!(
"{}",
serde_json::to_string(&Ready {
node,
width,
height
})
.unwrap()
);
let _ = std::io::stdout().flush();
}
}
pw::stream::StreamState::Unconnected if data.announced => {
data.stop.store(true, Ordering::Relaxed);
}
pw::stream::StreamState::Error(error) => {
eprintln!("PipeWire stream: {error}");
data.stop.store(true, Ordering::Relaxed);
}
_ => (),
}
})
.param_changed(move |stream, data, id, param| {
if id != spa::param::ParamType::Format.as_raw() {
return;
}
let Some(param) = param else { return };
let mut format = spa::param::video::VideoInfoRaw::default();
if format.parse(param).is_err()
|| format.size().width != width
|| format.size().height != height
|| format.format() != spa::param::video::VideoFormat::BGRx
{
data.stop.store(true, Ordering::Relaxed);
return;
}
let buffers = pod(spa::pod::object!(
spa::utils::SpaTypes::ObjectParamBuffers,
spa::param::ParamType::Buffers,
int_property(spa::sys::SPA_PARAM_BUFFERS_buffers, 4),
int_property(spa::sys::SPA_PARAM_BUFFERS_blocks, 1),
int_property(
spa::sys::SPA_PARAM_BUFFERS_size,
(width * height * 4) as i32
),
int_property(spa::sys::SPA_PARAM_BUFFERS_stride, (width * 4) as i32),
int_property(spa::sys::SPA_PARAM_BUFFERS_align, 16)
));
if stream
.update_params(&mut [Pod::from_bytes(&buffers).unwrap()])
.is_err()
{
data.stop.store(true, Ordering::Relaxed);
}
})
.process(|stream, data| {
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
};
let Some(target) = buffer.datas_mut().first_mut() else {
return;
};
if data.stop.load(Ordering::Relaxed) || !data.fresh.load(Ordering::SeqCst) {
*target.chunk_mut().size_mut() = 0;
return;
}
let frame = data.latest.lock().unwrap();
let row = frame.width as usize * 4;
let bytes = row * frame.height as usize;
let Some(destination) = target.data() else {
return;
};
if destination.len() < bytes {
data.stop.store(true, Ordering::Relaxed);
return;
}
for (dst, src) in destination[..bytes]
.chunks_exact_mut(row)
.zip(frame.pixels.chunks_exact(frame.stride as usize))
{
dst.copy_from_slice(&src[..row]);
}
*target.chunk_mut().offset_mut() = 0;
*target.chunk_mut().size_mut() = bytes as u32;
*target.chunk_mut().stride_mut() = row as i32;
})
.register()?;
let format = pod(spa::pod::object!(
spa::utils::SpaTypes::ObjectParamFormat,
spa::param::ParamType::EnumFormat,
spa::pod::property!(
spa::param::format::FormatProperties::MediaType,
Id,
spa::param::format::MediaType::Video
),
spa::pod::property!(
spa::param::format::FormatProperties::MediaSubtype,
Id,
spa::param::format::MediaSubtype::Raw
),
spa::pod::property!(
spa::param::format::FormatProperties::VideoFormat,
Id,
spa::param::video::VideoFormat::BGRx
),
spa::pod::property!(
spa::param::format::FormatProperties::VideoSize,
Rectangle,
spa::utils::Rectangle { width, height }
),
spa::pod::property!(
spa::param::format::FormatProperties::VideoFramerate,
Fraction,
spa::utils::Fraction { num: 30, denom: 1 }
)
));
stream.connect(
spa::utils::Direction::Output,
None,
pw::stream::StreamFlags::MAP_BUFFERS | pw::stream::StreamFlags::DRIVER,
&mut [Pod::from_bytes(&format).unwrap()],
)?;
let interval = Duration::from_nanos(1_000_000_000 / 30);
let capture_stop = stop.clone();
let capture_streaming = streaming.clone();
let capture_thread = std::thread::spawn(move || {
let mut spare = Vec::new();
let mut checked = Instant::now() - Duration::from_secs(1);
while !capture_stop.load(Ordering::Relaxed) {
let start = Instant::now();
if capture_streaming.load(Ordering::Relaxed)
|| checked.elapsed() >= Duration::from_secs(1)
{
checked = Instant::now();
match capture.frame(&name, cursor, &capture_stop, std::mem::take(&mut spare)) {
Ok(frame) if frame.width == width && frame.height == height => {
spare = std::mem::replace(&mut *latest.lock().unwrap(), frame).pixels;
fresh.store(capture_streaming.load(Ordering::Relaxed), Ordering::SeqCst);
}
Ok(_) => {
eprintln!("Shared monitor changed size; start sharing again");
capture_stop.store(true, Ordering::Relaxed);
}
Err(error) => {
eprintln!("{error}");
capture_stop.store(true, Ordering::Relaxed);
}
}
}
std::thread::sleep(interval.saturating_sub(start.elapsed()));
}
});
let mut next_frame = Instant::now();
while !stop.load(Ordering::Relaxed) {
let active = streaming.load(Ordering::Relaxed);
let timeout = if active {
next_frame
.saturating_duration_since(Instant::now())
.min(Duration::from_millis(10))
} else {
Duration::from_millis(10)
};
mainloop
.loop_()
.iterate(pw::loop_::Timeout::Finite(timeout));
if streaming.load(Ordering::Relaxed) && Instant::now() >= next_frame {
let _ = stream.trigger_process();
next_frame = Instant::now() + interval;
} else if !streaming.load(Ordering::Relaxed) {
next_frame = Instant::now();
}
}
let _ = stream.disconnect();
let _ = capture_thread.join();
Ok(())
}