c3e0c47d32
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>
247 lines
9.6 KiB
Rust
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");
|
|
}
|