Add screen-sharing support on Wayland/Linux (#51957)

Release Notes:

- Added screen-sharing support on Wayland/Linux.

---------

Co-authored-by: Neel Chotai <neel@zed.dev>
This commit is contained in:
Jakub Konka 2026-03-19 22:10:42 +01:00 committed by GitHub
parent 55a59ca17d
commit 01fe4f694e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 606 additions and 37 deletions

View file

@ -19,7 +19,8 @@ test-support = [
"gpui/test-support",
"livekit_client/test-support",
"project/test-support",
"util/test-support"
"util/test-support",
"workspace/test-support"
]
[dependencies]
@ -51,5 +52,6 @@ gpui = { workspace = true, features = ["test-support"] }
language = { workspace = true, features = ["test-support"] }
project = { workspace = true, features = ["test-support"] }
util = { workspace = true, features = ["test-support"] }
workspace = { workspace = true, features = ["test-support"] }
livekit_client = { workspace = true, features = ["test-support"] }

View file

@ -1529,6 +1529,84 @@ impl Room {
})
}
#[cfg(target_os = "linux")]
pub fn share_screen_wayland(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
log::info!("will screenshare on wayland");
if self.status.is_offline() {
return Task::ready(Err(anyhow!("room is offline")));
}
if self.is_sharing_screen() {
return Task::ready(Err(anyhow!("screen was already shared")));
}
let (participant, publish_id) = if let Some(live_kit) = self.live_kit.as_mut() {
let publish_id = post_inc(&mut live_kit.next_publish_id);
live_kit.screen_track = LocalTrack::Pending { publish_id };
cx.notify();
(live_kit.room.local_participant(), publish_id)
} else {
return Task::ready(Err(anyhow!("live-kit was not initialized")));
};
cx.spawn(async move |this, cx| {
let publication = participant.publish_screenshare_track_wayland(cx).await;
this.update(cx, |this, cx| {
let live_kit = this
.live_kit
.as_mut()
.context("live-kit was not initialized")?;
let canceled = if let LocalTrack::Pending {
publish_id: cur_publish_id,
} = &live_kit.screen_track
{
*cur_publish_id != publish_id
} else {
true
};
match publication {
Ok((publication, stream, failure_rx)) => {
if canceled {
cx.spawn(async move |_, cx| {
participant.unpublish_track(publication.sid(), cx).await
})
.detach()
} else {
cx.spawn(async move |this, cx| {
if failure_rx.await.is_ok() {
log::warn!("Wayland capture died, auto-unsharing screen");
let _ =
this.update(cx, |this, cx| this.unshare_screen(false, cx));
}
})
.detach();
live_kit.screen_track = LocalTrack::Published {
track_publication: publication,
_stream: stream,
};
cx.notify();
}
Audio::play_sound(Sound::StartScreenshare, cx);
Ok(())
}
Err(error) => {
if canceled {
Ok(())
} else {
live_kit.screen_track = LocalTrack::None;
cx.notify();
Err(error)
}
}
}
})?
})
}
pub fn toggle_mute(&mut self, cx: &mut Context<Self>) {
if let Some(live_kit) = self.live_kit.as_mut() {
// When unmuting, undeafen if the user was deafened before.

View file

@ -6596,6 +6596,151 @@ async fn test_join_call_after_screen_was_shared(
});
}
#[cfg(target_os = "linux")]
#[gpui::test(iterations = 10)]
async fn test_share_screen_wayland(
executor: BackgroundExecutor,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
let active_call_b = cx_b.read(ActiveCall::global);
// User A calls user B.
active_call_a
.update(cx_a, |call, cx| {
call.invite(client_b.user_id().unwrap(), None, cx)
})
.await
.unwrap();
// User B accepts.
let mut incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming());
executor.run_until_parked();
incoming_call_b.next().await.unwrap().unwrap();
active_call_b
.update(cx_b, |call, cx| call.accept_incoming(cx))
.await
.unwrap();
let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone());
let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone());
executor.run_until_parked();
// User A shares their screen via the Wayland path.
let events_b = active_call_events(cx_b);
active_call_a
.update(cx_a, |call, cx| {
call.room()
.unwrap()
.update(cx, |room, cx| room.share_screen_wayland(cx))
})
.await
.unwrap();
executor.run_until_parked();
// Room A is sharing and has a nonzero synthetic screen ID.
room_a.read_with(cx_a, |room, _| {
assert!(room.is_sharing_screen());
let screen_id = room.shared_screen_id();
assert!(screen_id.is_some(), "shared_screen_id should be Some");
assert_ne!(screen_id.unwrap(), 0, "synthetic ID must be nonzero");
});
// User B observes the remote screen sharing track.
assert_eq!(events_b.borrow().len(), 1);
if let call::room::Event::RemoteVideoTracksChanged { participant_id } =
events_b.borrow().first().unwrap()
{
assert_eq!(*participant_id, client_a.peer_id().unwrap());
room_b.read_with(cx_b, |room, _| {
assert_eq!(
room.remote_participants()[&client_a.user_id().unwrap()]
.video_tracks
.len(),
1
);
});
} else {
panic!("expected RemoteVideoTracksChanged event");
}
}
#[cfg(target_os = "linux")]
#[gpui::test(iterations = 10)]
async fn test_unshare_screen_wayland(
executor: BackgroundExecutor,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
let active_call_b = cx_b.read(ActiveCall::global);
// User A calls user B.
active_call_a
.update(cx_a, |call, cx| {
call.invite(client_b.user_id().unwrap(), None, cx)
})
.await
.unwrap();
// User B accepts.
let mut incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming());
executor.run_until_parked();
incoming_call_b.next().await.unwrap().unwrap();
active_call_b
.update(cx_b, |call, cx| call.accept_incoming(cx))
.await
.unwrap();
let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone());
executor.run_until_parked();
// User A shares their screen via the Wayland path.
active_call_a
.update(cx_a, |call, cx| {
call.room()
.unwrap()
.update(cx, |room, cx| room.share_screen_wayland(cx))
})
.await
.unwrap();
executor.run_until_parked();
room_a.read_with(cx_a, |room, _| {
assert!(room.is_sharing_screen());
});
// User A stops sharing.
room_a
.update(cx_a, |room, cx| room.unshare_screen(true, cx))
.unwrap();
executor.run_until_parked();
// Room A is no longer sharing, screen ID is gone.
room_a.read_with(cx_a, |room, _| {
assert!(!room.is_sharing_screen());
assert!(room.shared_screen_id().is_none());
});
}
#[gpui::test]
async fn test_right_click_menu_behind_collab_panel(cx: &mut TestAppContext) {
let mut server = TestServer::start(cx.executor().clone()).await;

View file

@ -171,6 +171,7 @@ pub fn init(cx: &mut App) {
});
});
});
// TODO(jk): Is this action ever triggered?
workspace.register_action(|_, _: &ScreenShare, window, cx| {
let room = ActiveCall::global(cx).read(cx).room().cloned();
if let Some(room) = room {
@ -179,19 +180,32 @@ pub fn init(cx: &mut App) {
if room.is_sharing_screen() {
room.unshare_screen(true, cx).ok();
} else {
let sources = cx.screen_capture_sources();
#[cfg(target_os = "linux")]
let is_wayland = gpui::guess_compositor() == "Wayland";
#[cfg(not(target_os = "linux"))]
let is_wayland = false;
cx.spawn(async move |room, cx| {
let sources = sources.await??;
let first = sources.into_iter().next();
if let Some(first) = first {
room.update(cx, |room, cx| room.share_screen(first, cx))?
.await
} else {
Ok(())
#[cfg(target_os = "linux")]
{
if is_wayland {
room.share_screen_wayland(cx).detach_and_log_err(cx);
}
})
.detach_and_log_err(cx);
}
if !is_wayland {
let sources = cx.screen_capture_sources();
cx.spawn(async move |room, cx| {
let sources = sources.await??;
let first = sources.into_iter().next();
if let Some(first) = first {
room.update(cx, |room, cx| room.share_screen(first, cx))?
.await
} else {
Ok(())
}
})
.detach_and_log_err(cx);
}
};
});
});

View file

@ -78,6 +78,7 @@ pub use test::{TestDispatcher, TestScreenCaptureSource, TestScreenCaptureStream}
#[cfg(all(target_os = "macos", any(test, feature = "test-support")))]
pub use visual_test::VisualTestPlatform;
// TODO(jk): return an enum instead of a string
/// Return which compositor we're guessing we'll use.
/// Does not attempt to connect to the given compositor.
#[cfg(any(target_os = "linux", target_os = "freebsd"))]

View file

@ -57,7 +57,7 @@ pub(crate) trait LinuxClient {
#[cfg(feature = "screen-capture")]
fn is_screen_capture_supported(&self) -> bool {
false
true
}
#[cfg(feature = "screen-capture")]

View file

@ -702,11 +702,6 @@ impl LinuxClient for WaylandClient {
None
}
#[cfg(feature = "screen-capture")]
fn is_screen_capture_supported(&self) -> bool {
false
}
#[cfg(feature = "screen-capture")]
fn screen_capture_sources(
&self,

View file

@ -9,6 +9,8 @@ use playback::capture_local_video_track;
use settings::Settings;
use std::sync::{Arc, atomic::AtomicU64};
#[cfg(target_os = "linux")]
mod linux;
mod playback;
use crate::{
@ -231,6 +233,33 @@ impl LocalParticipant {
.map(LocalTrackPublication)
.context("unpublishing a track")
}
#[cfg(target_os = "linux")]
pub async fn publish_screenshare_track_wayland(
&self,
cx: &mut AsyncApp,
) -> Result<(
LocalTrackPublication,
Box<dyn ScreenCaptureStream>,
futures::channel::oneshot::Receiver<()>,
)> {
let (track, stop_flag, feed_task, failure_rx) =
linux::start_wayland_desktop_capture(cx).await?;
let options = livekit::options::TrackPublishOptions {
source: livekit::track::TrackSource::Screenshare,
video_codec: livekit::options::VideoCodec::VP8,
..Default::default()
};
let publication = self
.publish_track(livekit::track::LocalTrack::Video(track.0), options, cx)
.await?;
Ok((
publication,
Box::new(linux::WaylandScreenCaptureStream::new(stop_flag, feed_task)),
failure_rx,
))
}
}
impl LocalTrackPublication {

View file

@ -0,0 +1,206 @@
use anyhow::Result;
use futures::StreamExt as _;
use futures::channel::oneshot;
use gpui::{AsyncApp, ScreenCaptureStream};
use livekit::track;
use livekit::webrtc::{
video_frame::{VideoFrame, VideoRotation},
video_source::{RtcVideoSource, VideoResolution, native::NativeVideoSource},
};
use std::{
sync::{
Arc,
atomic::{AtomicBool, AtomicU64, Ordering},
},
time::Duration,
};
static NEXT_WAYLAND_SHARE_ID: AtomicU64 = AtomicU64::new(1);
pub struct WaylandScreenCaptureStream {
id: u64,
stop_flag: Arc<AtomicBool>,
_feed_task: gpui::Task<()>,
}
impl WaylandScreenCaptureStream {
pub fn new(stop_flag: Arc<AtomicBool>, feed_task: gpui::Task<()>) -> Self {
Self {
id: NEXT_WAYLAND_SHARE_ID.fetch_add(1, Ordering::Relaxed),
stop_flag,
_feed_task: feed_task,
}
}
}
impl ScreenCaptureStream for WaylandScreenCaptureStream {
fn metadata(&self) -> Result<gpui::SourceMetadata> {
Ok(gpui::SourceMetadata {
id: self.id,
label: None,
is_main: None,
resolution: gpui::size(gpui::DevicePixels(1), gpui::DevicePixels(1)),
})
}
}
impl Drop for WaylandScreenCaptureStream {
fn drop(&mut self) {
self.stop_flag.store(true, Ordering::Relaxed);
}
}
struct CapturedFrame {
width: u32,
height: u32,
stride: u32,
data: Vec<u8>,
}
fn desktop_frame_to_nv12(frame: &CapturedFrame) -> livekit::webrtc::prelude::NV12Buffer {
use libwebrtc::native::yuv_helper::argb_to_nv12;
use livekit::webrtc::prelude::NV12Buffer;
let mut buffer = NV12Buffer::new(frame.width, frame.height);
let (stride_y, stride_uv) = buffer.strides();
let (data_y, data_uv) = buffer.data_mut();
argb_to_nv12(
&frame.data,
frame.stride,
data_y,
stride_y,
data_uv,
stride_uv,
frame.width as i32,
frame.height as i32,
);
buffer
}
pub(crate) async fn start_wayland_desktop_capture(
cx: &mut AsyncApp,
) -> Result<(
crate::LocalVideoTrack,
Arc<AtomicBool>,
gpui::Task<()>,
oneshot::Receiver<()>,
)> {
use futures::channel::mpsc;
use gpui::FutureExt as _;
use libwebrtc::desktop_capturer::{
CaptureError, DesktopCaptureSourceType, DesktopCapturer, DesktopCapturerOptions,
};
let (frame_tx, mut frame_rx) = mpsc::channel::<CapturedFrame>(2);
let stop_flag = Arc::new(AtomicBool::new(false));
let stop = stop_flag.clone();
let permanent_error = Arc::new(AtomicBool::new(false));
let permanent_error_cb = permanent_error.clone();
let executor = cx.background_executor().clone();
let capture_executor = executor.clone();
executor
.spawn(async move {
let mut options = DesktopCapturerOptions::new(DesktopCaptureSourceType::Generic);
options.set_include_cursor(true);
let Some(mut capturer) = DesktopCapturer::new(options) else {
log::error!(
"Failed to create Wayland desktop capturer. Is xdg-desktop-portal running?"
);
return;
};
let frame_tx_cb = parking_lot::Mutex::new(frame_tx.clone());
capturer.start_capture(None, move |result| match result {
Ok(frame) => {
let captured = CapturedFrame {
width: frame.width() as u32,
height: frame.height() as u32,
stride: frame.stride(),
data: frame.data().to_vec(),
};
frame_tx_cb.lock().try_send(captured).ok();
}
Err(CaptureError::Temporary) => {
// Expected before the portal picker completes
}
Err(CaptureError::Permanent) => {
permanent_error_cb.store(true, Ordering::Relaxed);
log::error!("Wayland desktop capture encountered a permanent error");
}
});
while !stop.load(Ordering::Relaxed) {
capturer.capture_frame();
if permanent_error.load(Ordering::Relaxed) {
break;
}
capture_executor.timer(Duration::from_millis(33)).await;
}
drop(frame_tx);
})
.detach();
let first_frame = frame_rx
.next()
.with_timeout(Duration::from_secs(15), &executor)
.await
.map_err(|_| {
stop_flag.store(true, Ordering::Relaxed);
anyhow::anyhow!(
"Screen sharing timed out waiting for the first frame. \
Check that xdg-desktop-portal and PipeWire are running, \
and that your portal backend matches your compositor."
)
})?
.ok_or_else(|| {
anyhow::anyhow!(
"Screen sharing was canceled or the portal denied permission. \
You can try again from the screen share button."
)
})?;
let width = first_frame.width;
let height = first_frame.height;
let video_source = gpui_tokio::Tokio::spawn(cx, async move {
NativeVideoSource::new(VideoResolution { width, height }, true)
})
.await?;
let nv12 = desktop_frame_to_nv12(&first_frame);
video_source.capture_frame(&VideoFrame {
rotation: VideoRotation::VideoRotation0,
timestamp_us: 0,
buffer: nv12,
});
let track = super::LocalVideoTrack(track::LocalVideoTrack::create_video_track(
"screen share",
RtcVideoSource::Native(video_source.clone()),
));
let (failure_tx, failure_rx) = oneshot::channel::<()>();
let feed_stop = stop_flag.clone();
let feed_task = cx.background_executor().spawn(async move {
while let Some(frame) = frame_rx.next().await {
if feed_stop.load(Ordering::Relaxed) {
break;
}
let nv12 = desktop_frame_to_nv12(&frame);
video_source.capture_frame(&VideoFrame {
rotation: VideoRotation::VideoRotation0,
timestamp_us: 0,
buffer: nv12,
});
}
if !feed_stop.load(Ordering::Relaxed) {
log::error!("Wayland screen capture ended unexpectedly");
let _ = failure_tx.send(());
}
});
Ok((track, stop_flag, feed_task, failure_rx))
}

View file

@ -99,6 +99,31 @@ impl LocalParticipant {
Box::new(TestScreenCaptureStream {}),
))
}
#[cfg(target_os = "linux")]
pub async fn publish_screenshare_track_wayland(
&self,
_cx: &mut AsyncApp,
) -> Result<(
LocalTrackPublication,
Box<dyn ScreenCaptureStream>,
futures::channel::oneshot::Receiver<()>,
)> {
let (_failure_tx, failure_rx) = futures::channel::oneshot::channel();
let this = self.clone();
let server = this.room.test_server();
let sid = server
.publish_video_track(this.room.token(), LocalVideoTrack {})
.await?;
Ok((
LocalTrackPublication {
room: self.room.downgrade(),
sid,
},
Box::new(TestWaylandScreenCaptureStream::new()),
failure_rx,
))
}
}
impl RemoteParticipant {
@ -166,3 +191,32 @@ impl ScreenCaptureStream for TestScreenCaptureStream {
})
}
}
#[cfg(target_os = "linux")]
static NEXT_TEST_WAYLAND_SHARE_ID: AtomicU64 = AtomicU64::new(1);
#[cfg(target_os = "linux")]
struct TestWaylandScreenCaptureStream {
id: u64,
}
#[cfg(target_os = "linux")]
impl TestWaylandScreenCaptureStream {
fn new() -> Self {
Self {
id: NEXT_TEST_WAYLAND_SHARE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
}
}
}
#[cfg(target_os = "linux")]
impl ScreenCaptureStream for TestWaylandScreenCaptureStream {
fn metadata(&self) -> Result<SourceMetadata> {
Ok(SourceMetadata {
id: self.id,
is_main: None,
label: None,
resolution: size(DevicePixels(1), DevicePixels(1)),
})
}
}

View file

@ -548,6 +548,11 @@ impl TitleBar {
);
if can_use_microphone && screen_sharing_supported {
#[cfg(target_os = "linux")]
let is_wayland = gpui::guess_compositor() == "Wayland";
#[cfg(not(target_os = "linux"))]
let is_wayland = false;
let trigger = IconButton::new("screen-share", IconName::Screen)
.style(ButtonStyle::Subtle)
.icon_size(IconSize::Small)
@ -564,28 +569,56 @@ impl TitleBar {
.room()
.is_some_and(|room| !room.read(cx).is_sharing_screen());
window
.spawn(cx, async move |cx| {
let screen = if should_share {
cx.update(|_, cx| pick_default_screen(cx))?.await
} else {
Ok(None)
};
cx.update(|window, cx| toggle_screen_sharing(screen, window, cx))?;
#[cfg(target_os = "linux")]
{
if is_wayland
&& let Some(room) = ActiveCall::global(cx).read(cx).room().cloned()
{
let task = room.update(cx, |room, cx| {
if should_share {
room.share_screen_wayland(cx)
} else {
room.unshare_screen(true, cx)
.map(|()| Task::ready(Ok(())))
.unwrap_or_else(|e| Task::ready(Err(e)))
}
});
task.detach_and_prompt_err(
"Sharing Screen Failed",
window,
cx,
|e, _, _| Some(format!("{e:?}")),
);
}
}
if !is_wayland {
window
.spawn(cx, async move |cx| {
let screen = if should_share {
cx.update(|_, cx| pick_default_screen(cx))?.await
} else {
Ok(None)
};
cx.update(|window, cx| toggle_screen_sharing(screen, window, cx))?;
Result::<_, anyhow::Error>::Ok(())
})
.detach();
Result::<_, anyhow::Error>::Ok(())
})
.detach();
}
});
children.push(
SplitButton::new(
trigger.render(window, cx),
self.render_screen_list().into_any_element(),
)
.style(SplitButtonStyle::Transparent)
.into_any_element(),
);
if is_wayland {
children.push(trigger.into_any_element());
} else {
children.push(
SplitButton::new(
trigger.render(window, cx),
self.render_screen_list().into_any_element(),
)
.style(SplitButtonStyle::Transparent)
.into_any_element(),
);
}
}
children.push(div().pr_2().into_any_element());

View file

@ -50,6 +50,8 @@ if [[ -n $apt ]]; then
musl-tools
musl-dev
build-essential
pipewire
xdg-desktop-portal
)
if (grep -qP 'PRETTY_NAME="(Debian|Raspbian).+13' /etc/os-release); then
# libstdc++-14-dev is in build-essential
@ -110,6 +112,8 @@ if [[ -n $dnf ]] || [[ -n $yum ]]; then
libzstd-devel
vulkan-loader
sqlite-devel
pipewire
xdg-desktop-portal
jq
git
tar
@ -185,6 +189,8 @@ if [[ -n $zyp ]]; then
tar
wayland-devel
xcb-util-devel
pipewire
xdg-desktop-portal
)
$maysudo "$zyp" install -y "${deps[@]}"
finalize
@ -213,6 +219,8 @@ if [[ -n $pacman ]]; then
pkgconf
mold
sqlite
pipewire
xdg-desktop-portal
jq
git
)
@ -244,6 +252,8 @@ if [[ -n $xbps ]]; then
vulkan-loader
mold
sqlite-devel
pipewire
xdg-desktop-portal
)
$maysudo "$xbps" -Syu "${deps[@]}"
finalize
@ -269,6 +279,8 @@ if [[ -n $emerge ]]; then
x11-libs/libxkbcommon
sys-devel/mold
dev-db/sqlite
media-video/pipewire
sys-apps/xdg-desktop-portal
)
$maysudo "$emerge" -u "${deps[@]}"
finalize