Add native ScreenCast portal with PipeWire monitor streams
This commit is contained in:
1 parent
cb512eece9
commit
e7d92280d6
21 files changed
+2228
-15
No files matched your search
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in new issue
Block a user