Files
ChatApp/apps/desktop/src-tauri/src/screen_sources.rs
T
byGalax c3e0c47d32 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>
2026-04-22 21:42:52 +02:00

280 lines
9.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, 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.
// Thumbnails are captured at native resolution, then letterbox-downscaled
// to fit a 320×180 box to keep the base64 payload small.
use base64::Engine;
use image::{ImageBuffer, Rgba};
use serde::Serialize;
const THUMB_MAX_W: u32 = 320;
const THUMB_MAX_H: u32 = 180;
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ScreenSource {
/// Chromium-format source id — stable within one enumeration call.
pub id: String,
/// Human-readable label for the picker (monitor name or window title).
pub name: String,
/// Discriminator for the grid grouping.
pub kind: &'static str,
/// Base64-encoded PNG, no data-URL prefix. None when the capture
/// fails (minimised window, permission-denied surface, transient
/// race). The UI renders a name-only card in that case.
pub thumbnail_png: Option<String>,
/// Native width of the full-res source — mostly informational, used
/// by the UI for aspect-ratio styling of the card.
pub width: u32,
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();
append_monitors(&mut out);
append_windows(&mut out);
Ok(out)
}
// ---------------------------------------------------------------------------
// 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,
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: 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() {
format!("Bildschirm {}", idx + 1)
} else {
name.to_string()
}
}
fn capture_monitor_thumbnail(m: &xcap::Monitor) -> Option<String> {
let image = m.capture_image().ok()?;
encode_scaled_png(image)
}
// ---------------------------------------------------------------------------
// 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,
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: 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;
}
let width = w.width();
let height = w.height();
// Tooltips, invisible tray-helpers, etc. sit at or near zero size —
// they'd clutter the picker grid and usually can't be captured anyway.
if width < 80 || height < 60 {
return false;
}
true
}
fn capture_window_thumbnail(w: &xcap::Window) -> Option<String> {
let image = w.capture_image().ok()?;
encode_scaled_png(image)
}
// ---------------------------------------------------------------------------
// Scaling + encoding
// ---------------------------------------------------------------------------
// Letterbox-shrink the captured image so the long edge is at most
// THUMB_MAX_W / THUMB_MAX_H. Keeps aspect ratio, skips upscaling entirely
// (tiny windows stay their captured size). Returns a base64 PNG string or
// None if encoding fails.
fn encode_scaled_png(src: ImageBuffer<Rgba<u8>, Vec<u8>>) -> Option<String> {
let (w, h) = src.dimensions();
if w == 0 || h == 0 {
return None;
}
let scale = (THUMB_MAX_W as f32 / w as f32)
.min(THUMB_MAX_H as f32 / h as f32)
.min(1.0);
let scaled = if scale < 1.0 {
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;
image::imageops::resize(&src, new_w, new_h, image::imageops::FilterType::Triangle)
} else {
src
};
let mut buf: Vec<u8> = Vec::new();
let encoder = image::codecs::png::PngEncoder::new_with_quality(
&mut buf,
image::codecs::png::CompressionType::Fast,
image::codecs::png::FilterType::Adaptive,
);
use image::ImageEncoder;
encoder
.write_image(
scaled.as_raw(),
scaled.width(),
scaled.height(),
image::ExtendedColorType::Rgba8,
)
.ok()?;
Some(base64::engine::general_purpose::STANDARD.encode(&buf))
}