Files
ChatApp/apps/desktop/src-tauri/src/lib.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

247 lines
9.6 KiB
Rust

mod crypto;
mod screen_capture;
mod screen_sources;
#[cfg(feature = "rust-livekit")]
mod livekit_bridge;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
use tauri::{
menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Listener, Manager,
};
#[cfg(not(any(target_os = "android", target_os = "ios")))]
use serde::Deserialize;
// Payload for the `tray-unread-update` event the JS layer emits whenever the
// aggregate unread-count changes. 0 hides the badge / resets the tooltip;
// non-zero sets a count indicator.
#[cfg(not(any(target_os = "android", target_os = "ios")))]
#[derive(Deserialize)]
struct TrayUnreadPayload {
count: u32,
}
// Red-dot overlay icon for the Windows taskbar. Drawn as raw RGBA instead of
// shipping a PNG so we don't add another resource to the bundle. Kept small
// (32x32) since Windows scales the overlay down anyway.
#[cfg(target_os = "windows")]
fn unread_overlay_rgba() -> Vec<u8> {
const SIZE: u32 = 32;
let r = SIZE as f32 / 2.0;
let mut buf = Vec::with_capacity((SIZE * SIZE * 4) as usize);
for y in 0..SIZE {
for x in 0..SIZE {
let dx = x as f32 - r + 0.5;
let dy = y as f32 - r + 0.5;
let d = (dx * dx + dy * dy).sqrt();
let edge = r - 1.0;
if d <= edge {
buf.extend_from_slice(&[0xDC, 0x26, 0x26, 0xFF]);
} else if d <= r {
let alpha = (255.0 * (r - d)).clamp(0.0, 255.0) as u8;
buf.extend_from_slice(&[0xDC, 0x26, 0x26, alpha]);
} else {
buf.extend_from_slice(&[0, 0, 0, 0]);
}
}
}
buf
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
fn show_main_window(app: &AppHandle) {
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
let _ = win.unminimize();
let _ = win.set_focus();
}
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
fn hide_main_window(app: &AppHandle) {
if let Some(win) = app.get_webview_window("main") {
let _ = win.hide();
}
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
fn handle_menu_event(app: &AppHandle, event: MenuEvent) {
match event.id.as_ref() {
"tray-show" => show_main_window(app),
"tray-hide" => hide_main_window(app),
"tray-quit" => {
app.exit(0);
}
_ => {}
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
#[cfg(not(feature = "rust-livekit"))]
let mut builder = tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
crypto::crypto_random_bytes,
crypto::crypto_secretbox_encrypt,
crypto::crypto_secretbox_decrypt,
crypto::crypto_box_keypair,
crypto::crypto_box_encrypt,
crypto::crypto_box_decrypt,
crypto::crypto_box_seal,
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());
#[cfg(feature = "rust-livekit")]
let mut builder = tauri::Builder::default()
.manage(livekit_bridge::LivekitState::new())
.invoke_handler(tauri::generate_handler![
crypto::crypto_random_bytes,
crypto::crypto_secretbox_encrypt,
crypto::crypto_secretbox_decrypt,
crypto::crypto_box_keypair,
crypto::crypto_box_encrypt,
crypto::crypto_box_decrypt,
crypto::crypto_box_seal,
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,
livekit_bridge::livekit_set_mic,
livekit_bridge::livekit_set_camera,
])
.plugin(tauri_plugin_notification::init());
builder = builder
.plugin(tauri_plugin_sql::Builder::default().build())
.plugin(tauri_plugin_fs::init())
.plugin(
tauri_plugin_stronghold::Builder::new(|password| {
// TODO: derive stronghold key from password using argon2 / blake2b.
// Placeholder so project compiles.
password.as_bytes().to_vec()
})
.build(),
);
// Global shortcut + updater + window-state plugins are desktop-only
// (no mobile support — mobile windows are OS-managed).
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
builder = builder
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_window_state::Builder::new().build());
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
builder = builder.setup(|app| {
// Tray icon with a minimal menu. Left-click toggles window
// visibility; right-click shows the menu. Badge / tooltip updates
// come from the JS side via `tray-unread-update` events.
let show = MenuItem::with_id(app, "tray-show", "Öffnen", true, None::<&str>)?;
let hide = MenuItem::with_id(app, "tray-hide", "Ausblenden", true, None::<&str>)?;
let sep = PredefinedMenuItem::separator(app)?;
let quit = MenuItem::with_id(app, "tray-quit", "Beenden", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &hide, &sep, &quit])?;
let mut tray_builder = TrayIconBuilder::with_id("chatapp-tray")
.menu(&menu)
.show_menu_on_left_click(false)
.tooltip("ChatApp")
.on_menu_event(|app, event| handle_menu_event(app, event))
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(win) = app.get_webview_window("main") {
if win.is_visible().unwrap_or(false) {
let _ = win.hide();
} else {
let _ = win.show();
let _ = win.set_focus();
}
}
}
});
// `default_window_icon` returns Option<&Image>; only attach if
// we actually have one bundled (should always be true via the
// tauri.conf.json icon list, but guard to stay typesafe).
if let Some(icon) = app.default_window_icon() {
tray_builder = tray_builder.icon(icon.clone());
}
let tray = tray_builder.build(app)?;
// Listen for JS-side unread updates and mirror them into the tray
// tooltip + macOS dock badge. `tray` is cheap to clone (internal
// Arc) so we can move it into the listener closure directly.
let tray_handle = tray.clone();
let badge_window = app.get_webview_window("main");
app.listen("tray-unread-update", move |event| {
let Ok(payload) = serde_json::from_str::<TrayUnreadPayload>(event.payload())
else {
return;
};
let tooltip = if payload.count == 0 {
"ChatApp".to_string()
} else {
format!("ChatApp · {} neu", payload.count)
};
let _ = tray_handle.set_tooltip(Some(tooltip));
// Dock/taskbar badge. macOS uses a numeric label; Windows uses
// an overlay icon (red dot = unread). Linux has no cross-DE
// badge API — skip.
#[cfg(target_os = "macos")]
if let Some(win) = badge_window.as_ref() {
let badge = if payload.count == 0 {
None
} else {
Some(payload.count.to_string())
};
let _ = win.set_badge_label(badge);
}
#[cfg(target_os = "windows")]
if let Some(win) = badge_window.as_ref() {
if payload.count == 0 {
let _ = win.set_overlay_icon(None);
} else {
let rgba = unread_overlay_rgba();
let img = tauri::image::Image::new_owned(rgba, 32, 32);
let _ = win.set_overlay_icon(Some(img));
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
let _ = &badge_window;
});
Ok(())
});
}
builder
.run(tauri::generate_context!())
.expect("error while running tauri application");
}