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

Jakub Konka and Neel Chotai created

Release Notes:

- Added screen-sharing support on Wayland/Linux.

---------

Co-authored-by: Neel Chotai <neel@zed.dev>

Change summary

crates/call/Cargo.toml                               |   4 
crates/call/src/call_impl/room.rs                    |  78 +++++
crates/collab/tests/integration/integration_tests.rs | 145 +++++++++
crates/collab_ui/src/collab_panel.rs                 |  38 +
crates/gpui/src/platform.rs                          |   1 
crates/gpui_linux/src/linux/platform.rs              |   2 
crates/gpui_linux/src/linux/wayland/client.rs        |   5 
crates/livekit_client/src/livekit_client.rs          |  29 +
crates/livekit_client/src/livekit_client/linux.rs    | 206 ++++++++++++++
crates/livekit_client/src/mock_client/participant.rs |  54 +++
crates/title_bar/src/collab.rs                       |  71 +++-
script/linux                                         |  12 
12 files changed, 607 insertions(+), 38 deletions(-)

Detailed changes

crates/call/Cargo.toml 🔗

@@ -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"] }

crates/call/src/call_impl/room.rs 🔗

@@ -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.

crates/collab/tests/integration/integration_tests.rs 🔗

@@ -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;

crates/collab_ui/src/collab_panel.rs 🔗

@@ -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();
-
-                            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")]
+                            let is_wayland = gpui::guess_compositor() == "Wayland";
+                            #[cfg(not(target_os = "linux"))]
+                            let is_wayland = false;
+
+                            #[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);
+                            }
                         };
                     });
                 });

crates/gpui/src/platform.rs 🔗

@@ -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"))]

crates/gpui_linux/src/linux/platform.rs 🔗

@@ -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")]

crates/gpui_linux/src/linux/wayland/client.rs 🔗

@@ -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,

crates/livekit_client/src/livekit_client.rs 🔗

@@ -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 {

crates/livekit_client/src/livekit_client/linux.rs 🔗

@@ -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))
+}

crates/livekit_client/src/mock_client/participant.rs 🔗

@@ -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)),
+        })
+    }
+}

crates/title_bar/src/collab.rs 🔗

@@ -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());

script/linux 🔗

@@ -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