b44a785d20
Rust side:
- New src-tauri/src/screen_sources.rs with an enumerate_screen_sources
command. Uses the xcap crate for cross-platform screen + window
enumeration and capture; PNG thumbnails are letterbox-scaled to fit
320x180 and returned as base64.
- Source ids emit Chromium's internal desktopCapturer format
("screen:<id>:0", "window:<hwnd>:0") so JS can try passing them
straight into chromeMediaSourceId.
- Registered in both invoke_handler branches in lib.rs.
Frontend:
- New lib/screenSources.ts — Tauri command wrapper + thumbnailDataUrl
helper for the picker UI.
- New components/ScreenSourcePicker.tsx — Discord-style grid: sources
grouped under "Bildschirme" / "Fenster", large thumbnail cards with
selection state, quality preset + system-audio toggle in the footer.
"Teilen" button is enabled either way; without a selection it says
"Ohne Auswahl weiter" and falls through to the OS picker.
- Replaces the old form-style ScreenShareDialog entirely (removed).
CallContext wiring:
- startScreenShare now accepts an optional sourceId. When set, it
captures that exact source via getUserMedia's legacy
chromeMediaSourceId constraint and publishes the resulting tracks
manually (video as ScreenShare, audio as ScreenShareAudio). Falls
back to setScreenShareEnabled if WebView2 rejects the constraint,
so users always get a working share even if the direct path fails.
- Track 'ended' listeners unpublish the pub when the OS revokes
capture (close of shared window, OS "stop sharing" banner).
InCallPanel:
- Left-click on the share button now opens the picker instead of
starting with last-saved settings; right-click opens it too. The
picker itself is the 1-click UX.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
238 lines
9.1 KiB
Rust
238 lines
9.1 KiB
Rust
mod crypto;
|
|
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,
|
|
])
|
|
.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,
|
|
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");
|
|
}
|