feat(call): native screen-capture pipeline + faster picker

Picker speed (Phase 1+2):
- screen_sources.rs split into list_screen_sources (metadata only,
  returns in ~10ms) + capture_screen_source_thumbnail (single source,
  by id). ScreenSourcePicker now shows names + placeholders instantly
  and streams thumbnails in as each capture lands. Total wall-clock
  is bounded by the slowest source instead of the serial sum.
- enumerate_screen_sources kept as a dead_code fallback so any
  rollout regression can switch the frontend back without code loss.

Native capture (Phase 3):
- New src-tauri/src/screen_capture.rs. start_screen_capture spawns a
  Rust thread per share that grabs frames via xcap, downscales to the
  user's quality preset, JPEG-encodes at Q72, and streams each frame
  through a Tauri Channel<FramePayload>. stop_screen_capture signals
  the stop flag and joins the worker.
- Worker re-resolves the xcap handle inside the thread because
  xcap::Window holds a !Send HWND — passing the source id string
  across the thread boundary sidesteps that.
- New lib/screenCapture.ts: decodes each frame into an ImageBitmap,
  draws to an offscreen canvas, exposes canvas.captureStream() as the
  MediaStream LiveKit publishes. Latest-wins frame queue drops stale
  frames when the JS side falls behind the Rust producer. 3s first-
  frame timeout so a silently-failing source (locked screen, DRM
  window) surfaces as a clean NativeCaptureUnavailable and we fall
  back to getDisplayMedia.
- CallContext.startScreenShare takes the native path first when the
  picker provided a sourceId and system audio wasn't requested. The
  old chromeMediaSourceId attempt and final setScreenShareEnabled
  fallback stay in place for the audio case + non-Tauri runtimes.
- stopScreenShare kills the native handle first, then unpublishes any
  manually-published ScreenShare/ScreenShareAudio tracks, then falls
  back to setScreenShareEnabled(false). disconnectRoom also stops
  the handle so we don't leak Rust threads across calls.

Scope note: native path is video-only. System-audio capture needs
WASAPI-loopback (Windows) or ScreenCaptureKit-audio (macOS); until
those are wired, requesting audio in the picker falls through to
the getDisplayMedia path and shows the OS picker for that one case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-04-22 21:42:52 +02:00
parent 8f9b823d69
commit c3e0c47d32
7 changed files with 752 additions and 40 deletions
+9
View File
@@ -1,4 +1,5 @@
mod crypto;
mod screen_capture;
mod screen_sources;
#[cfg(feature = "rust-livekit")]
@@ -93,6 +94,10 @@ pub fn run() {
crypto::crypto_box_seal_open,
crypto::crypto_pwhash,
screen_sources::enumerate_screen_sources,
screen_sources::list_screen_sources,
screen_sources::capture_screen_source_thumbnail,
screen_capture::start_screen_capture,
screen_capture::stop_screen_capture,
])
.plugin(tauri_plugin_notification::init());
@@ -110,6 +115,10 @@ pub fn run() {
crypto::crypto_box_seal_open,
crypto::crypto_pwhash,
screen_sources::enumerate_screen_sources,
screen_sources::list_screen_sources,
screen_sources::capture_screen_source_thumbnail,
screen_capture::start_screen_capture,
screen_capture::stop_screen_capture,
livekit_bridge::livekit_connect,
livekit_bridge::livekit_disconnect,
livekit_bridge::livekit_send_data,
@@ -0,0 +1,266 @@
// Native screen / window capture pipeline. Spawns a Rust thread per active
// capture that grabs frames via xcap, downscales them to the user's target
// resolution, encodes JPEG, and streams each frame to the JS side through
// a Tauri `Channel<FramePayload>`. The JS end decodes into an
// ImageBitmap, draws to a <canvas>, and exposes the canvas as a
// MediaStream via captureStream() — that stream is what LiveKit publishes.
// Net result: the user picks a source in our custom picker and the share
// starts directly, without the OS/browser picker appearing.
//
// JPEG on the Rust side + decode on the JS side introduces a second
// encoding hop (LiveKit re-encodes VP9 later) but keeps IPC bandwidth
// manageable — raw RGBA at 1920×1080×30fps would be ~240MB/s and cannot
// go over Tauri's JSON-serialised IPC. JPEG frames at Q72 land around
// 50150KB each, so 30fps = 25MB/s of base64 traffic, which is fine.
use base64::Engine;
use serde::Serialize;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::thread;
use std::time::{Duration, Instant};
use tauri::ipc::Channel;
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
static SESSIONS: OnceLock<Mutex<HashMap<u32, Session>>> = OnceLock::new();
fn sessions() -> &'static Mutex<HashMap<u32, Session>> {
SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
}
struct Session {
stop: Arc<AtomicBool>,
handle: Option<thread::JoinHandle<()>>,
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct FramePayload {
pub capture_id: u32,
pub width: u32,
pub height: u32,
/// JPEG image bytes, base64-encoded (no data-URL prefix). Frontend
/// reconstructs via `Uint8Array.from(atob(...))` and decodes with
/// `createImageBitmap(blob)`.
pub jpeg_base64: String,
}
enum Source {
Window(xcap::Window),
Monitor(xcap::Monitor),
}
/// Start a continuous capture for the given source id and begin streaming
/// JPEG frames via the provided channel. Returns a numeric capture id that
/// must be passed to `stop_screen_capture` to tear the pipeline down.
#[tauri::command]
pub fn start_screen_capture(
source_id: String,
max_width: u32,
max_height: u32,
fps: u32,
channel: Channel<FramePayload>,
) -> Result<u32, String> {
let clamped_fps = fps.clamp(5, 30);
let clamped_w = max_width.max(320).min(3840);
let clamped_h = max_height.max(180).min(2160);
// Probe once on the command thread so we can surface a clear error
// before spawning; the actual capture handle is re-resolved inside the
// worker thread (xcap::Window holds a Windows HWND which is !Send).
if find_source(&source_id).is_none() {
return Err(format!("screen source {source_id} not found"));
}
let capture_id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
let stop = Arc::new(AtomicBool::new(false));
let stop_clone = Arc::clone(&stop);
let source_id_owned = source_id;
let handle = thread::Builder::new()
.name(format!("screen-capture-{capture_id}"))
.spawn(move || {
capture_loop(
source_id_owned,
clamped_w,
clamped_h,
clamped_fps,
capture_id,
channel,
stop_clone,
);
})
.map_err(|e| format!("failed to spawn capture thread: {e}"))?;
sessions().lock().unwrap().insert(
capture_id,
Session {
stop,
handle: Some(handle),
},
);
Ok(capture_id)
}
/// Signal the capture thread to stop and join it. Safe to call more than
/// once — missing ids are silently no-ops so the JS side doesn't need to
/// track whether a stop was already issued by the OS "stop sharing" path.
#[tauri::command]
pub fn stop_screen_capture(capture_id: u32) -> Result<(), String> {
let session = sessions().lock().unwrap().remove(&capture_id);
let Some(mut session) = session else {
return Ok(());
};
session.stop.store(true, Ordering::Relaxed);
if let Some(handle) = session.handle.take() {
// Best-effort join. If the thread is stuck in a long OS capture
// call we don't want to hang the command — detach after a brief
// wait by dropping the handle.
let _ = handle.join();
}
Ok(())
}
fn capture_loop(
source_id: String,
max_w: u32,
max_h: u32,
fps: u32,
capture_id: u32,
channel: Channel<FramePayload>,
stop: Arc<AtomicBool>,
) {
let frame_interval = Duration::from_nanos(1_000_000_000 / fps as u64);
let jpeg_quality: u8 = 72;
// Re-resolve the source inside the worker thread — xcap::Window holds
// an HWND which is !Send so we can't move it across threads. Caching
// the handle for the lifetime of the loop keeps per-frame cost to the
// actual pixel capture + encode.
let source = match find_source(&source_id) {
Some(s) => s,
None => {
eprintln!("screen-capture {capture_id}: source vanished before capture started");
return;
}
};
while !stop.load(Ordering::Relaxed) {
let frame_start = Instant::now();
let img_result = match &source {
Source::Window(w) => w.capture_image(),
Source::Monitor(m) => m.capture_image(),
};
let img = match img_result {
Ok(i) => i,
Err(err) => {
eprintln!("screen-capture {capture_id}: capture failed: {err}");
// Brief backoff before retrying — transient Windows GDI
// errors (e.g. during screen lock) tend to recover within
// a frame or two.
thread::sleep(frame_interval);
continue;
}
};
let (raw_w, raw_h) = img.dimensions();
let (tgt_w, tgt_h) = scale_to_fit(raw_w, raw_h, max_w, max_h);
let scaled = if (tgt_w, tgt_h) != (raw_w, raw_h) {
image::imageops::resize(
&img,
tgt_w,
tgt_h,
image::imageops::FilterType::Triangle,
)
} else {
img
};
// JPEG doesn't support alpha; strip it before encoding.
let rgb = rgba_to_rgb(&scaled);
let mut jpeg_buf: Vec<u8> = Vec::with_capacity((tgt_w * tgt_h) as usize);
{
use image::codecs::jpeg::JpegEncoder;
let mut encoder = JpegEncoder::new_with_quality(&mut jpeg_buf, jpeg_quality);
if let Err(err) =
encoder.encode(&rgb, tgt_w, tgt_h, image::ExtendedColorType::Rgb8)
{
eprintln!("screen-capture {capture_id}: encode failed: {err}");
thread::sleep(frame_interval);
continue;
}
}
let jpeg_base64 = base64::engine::general_purpose::STANDARD.encode(&jpeg_buf);
let send_result = channel.send(FramePayload {
capture_id,
width: tgt_w,
height: tgt_h,
jpeg_base64,
});
if send_result.is_err() {
// Frontend went away (window closed, renderer crashed).
break;
}
// Pace to target framerate. If the capture + encode already took
// longer than one frame interval, yield a millisecond to avoid
// pegging a single core when the target is unreachable.
let elapsed = frame_start.elapsed();
if elapsed < frame_interval {
thread::sleep(frame_interval - elapsed);
} else {
thread::sleep(Duration::from_millis(1));
}
}
}
fn rgba_to_rgb(buf: &image::RgbaImage) -> Vec<u8> {
let (w, h) = buf.dimensions();
let mut out = Vec::with_capacity((w * h * 3) as usize);
for p in buf.pixels() {
out.extend_from_slice(&[p.0[0], p.0[1], p.0[2]]);
}
out
}
fn scale_to_fit(w: u32, h: u32, max_w: u32, max_h: u32) -> (u32, u32) {
if w == 0 || h == 0 {
return (w, h);
}
let scale = (max_w as f32 / w as f32)
.min(max_h as f32 / h as f32)
.min(1.0);
if scale >= 1.0 {
return (w, h);
}
let new_w = ((w as f32) * scale).round().max(1.0) as u32;
let new_h = ((h as f32) * scale).round().max(1.0) as u32;
(new_w, new_h)
}
fn find_source(source_id: &str) -> Option<Source> {
if let Some(rest) = source_id.strip_prefix("window:") {
let raw = rest.strip_suffix(":0")?;
let raw_id: u32 = raw.parse().ok()?;
let windows = xcap::Window::all().ok()?;
return windows
.into_iter()
.find(|w| w.id() == raw_id)
.map(Source::Window);
}
if let Some(rest) = source_id.strip_prefix("screen:") {
let raw = rest.strip_suffix(":0")?;
let raw_id: u32 = raw.parse().ok()?;
let monitors = xcap::Monitor::all().ok()?;
return monitors
.into_iter()
.find(|m| m.id() == raw_id)
.map(Source::Monitor);
}
None
}
+117 -29
View File
@@ -1,13 +1,18 @@
// Source enumeration for the Discord-style screen-share picker. Returns a
// flat list of screens + windows with small PNG thumbnails so the JS
// picker can render a grid without the browser's native picker.
// Source enumeration for the Discord-style screen-share picker. Split
// into two commands on the slow-vs-fast axis:
//
// - list_screen_sources — metadata only (no thumbnails). Fast; the
// picker shows names + placeholders instantly.
// - capture_screen_source_thumbnail — one thumbnail at a time, keyed by
// the id returned from the list.
//
// The frontend fans out the thumbnail calls via Promise.all so Tauri's
// command thread pool captures them in parallel — wall-clock time ends up
// bounded by the *slowest* source rather than the sum of all captures.
// The `id` field is emitted in Chromium's internal desktopCapturer format
// ("screen:<id>:0" / "window:<hwnd>:0") so the JS side can try to pass it
// straight into getUserMedia's `chromeMediaSourceId` constraint. That
// bypass may or may not be accepted by WebView2 depending on the version
// — if it isn't, the JS falls back to the ordinary getDisplayMedia flow
// and at least the user has seen an informed preview first.
// straight into getUserMedia's `chromeMediaSourceId` constraint, or use
// it as the source key for the native capture pipeline in screen_capture.
//
// xcap abstracts the platform-specific capture APIs (Windows GDI + DXGI,
// macOS CoreGraphics/ScreenCaptureKit, X11) so the code here stays flat.
@@ -40,6 +45,39 @@ pub struct ScreenSource {
pub height: u32,
}
// Fast metadata-only enumeration. No image capture happens here — that's
// why it returns in tens of milliseconds instead of the multi-second
// wait the single-shot enumerate_screen_sources command had.
#[tauri::command]
pub fn list_screen_sources() -> Result<Vec<ScreenSource>, String> {
let mut out: Vec<ScreenSource> = Vec::new();
append_monitor_metadata(&mut out);
append_window_metadata(&mut out);
Ok(out)
}
// One-shot thumbnail capture by source id. Called N times in parallel from
// the frontend after the list lands so the total wall-clock is bounded by
// the slowest capture rather than the sum.
#[tauri::command]
pub fn capture_screen_source_thumbnail(source_id: String) -> Result<Option<String>, String> {
if let Some(raw) = source_id.strip_prefix("screen:").and_then(strip_zero_suffix) {
return Ok(capture_monitor_by_id(raw));
}
if let Some(raw) = source_id.strip_prefix("window:").and_then(strip_zero_suffix) {
return Ok(capture_window_by_id(raw));
}
Err(format!("unknown source id format: {source_id}"))
}
fn strip_zero_suffix(s: &str) -> Option<&str> {
s.strip_suffix(":0")
}
// Legacy one-shot all-in-one enumeration. Kept around so the frontend can
// fall back during rollout if the split pair throws; marked dead_code so
// the linker doesn't grumble when only the split variant is wired up.
#[allow(dead_code)]
#[tauri::command]
pub fn enumerate_screen_sources() -> Result<Vec<ScreenSource>, String> {
let mut out: Vec<ScreenSource> = Vec::new();
@@ -52,6 +90,27 @@ pub fn enumerate_screen_sources() -> Result<Vec<ScreenSource>, String> {
// Monitors
// ---------------------------------------------------------------------------
fn append_monitor_metadata(out: &mut Vec<ScreenSource>) {
let monitors = match xcap::Monitor::all() {
Ok(m) => m,
Err(err) => {
eprintln!("xcap Monitor::all failed: {err}");
return;
}
};
for (idx, m) in monitors.iter().enumerate() {
out.push(ScreenSource {
id: format!("screen:{}:0", m.id()),
name: monitor_label(m, idx),
kind: "screen",
thumbnail_png: None,
width: m.width(),
height: m.height(),
});
}
}
#[allow(dead_code)]
fn append_monitors(out: &mut Vec<ScreenSource>) {
let monitors = match xcap::Monitor::all() {
Ok(m) => m,
@@ -61,25 +120,24 @@ fn append_monitors(out: &mut Vec<ScreenSource>) {
}
};
for (idx, m) in monitors.iter().enumerate() {
let name = monitor_label(m, idx);
let width = m.width();
let height = m.height();
// xcap exposes an OS-level monitor id; pass it through as the
// middle component so the JS side can correlate repeat enumerations.
let raw_id = m.id();
let id = format!("screen:{raw_id}:0");
let thumb = capture_monitor_thumbnail(m);
out.push(ScreenSource {
id,
name,
id: format!("screen:{}:0", m.id()),
name: monitor_label(m, idx),
kind: "screen",
thumbnail_png: thumb,
width,
height,
thumbnail_png: capture_monitor_thumbnail(m),
width: m.width(),
height: m.height(),
});
}
}
fn capture_monitor_by_id(raw: &str) -> Option<String> {
let raw_id: u32 = raw.parse().ok()?;
let monitors = xcap::Monitor::all().ok()?;
let target = monitors.into_iter().find(|m| m.id() == raw_id)?;
capture_monitor_thumbnail(&target)
}
fn monitor_label(m: &xcap::Monitor, idx: usize) -> String {
let name = m.name();
if name.is_empty() {
@@ -98,6 +156,34 @@ fn capture_monitor_thumbnail(m: &xcap::Monitor) -> Option<String> {
// Windows
// ---------------------------------------------------------------------------
fn append_window_metadata(out: &mut Vec<ScreenSource>) {
let windows = match xcap::Window::all() {
Ok(w) => w,
Err(err) => {
eprintln!("xcap Window::all failed: {err}");
return;
}
};
for w in windows.iter() {
if !is_shareable_window(w) {
continue;
}
let title = w.title();
if title.trim().is_empty() {
continue;
}
out.push(ScreenSource {
id: format!("window:{}:0", w.id()),
name: title.to_string(),
kind: "window",
thumbnail_png: None,
width: w.width(),
height: w.height(),
});
}
}
#[allow(dead_code)]
fn append_windows(out: &mut Vec<ScreenSource>) {
let windows = match xcap::Window::all() {
Ok(w) => w,
@@ -114,22 +200,24 @@ fn append_windows(out: &mut Vec<ScreenSource>) {
if title.trim().is_empty() {
continue;
}
let width = w.width();
let height = w.height();
let raw_id = w.id();
let id = format!("window:{raw_id}:0");
let thumb = capture_window_thumbnail(w);
out.push(ScreenSource {
id,
id: format!("window:{}:0", w.id()),
name: title.to_string(),
kind: "window",
thumbnail_png: thumb,
width,
height,
thumbnail_png: capture_window_thumbnail(w),
width: w.width(),
height: w.height(),
});
}
}
fn capture_window_by_id(raw: &str) -> Option<String> {
let raw_id: u32 = raw.parse().ok()?;
let windows = xcap::Window::all().ok()?;
let target = windows.into_iter().find(|w| w.id() == raw_id)?;
capture_window_thumbnail(&target)
}
fn is_shareable_window(w: &xcap::Window) -> bool {
if w.is_minimized() {
return false;