// Claude Desktop — Programm-Steuerung // D-Bus: Linux-Apps via dbus-send/qdbus // Xvfb: Virtuelles Display fuer Computer-Use (Scaffold) // Playwright: Tool-Hinweise (eigentliche Steuerung laeuft ueber MCP-Server) use serde::{Deserialize, Serialize}; use std::process::Command; // ============ D-Bus ============ #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DbusCallResult { pub success: bool, pub stdout: String, pub stderr: String, pub exit_code: i32, } #[tauri::command] pub async fn dbus_call( service: String, path: String, method: String, args: Option>, session: Option, ) -> Result { let bus = if session.unwrap_or(true) { "--session" } else { "--system" }; // Whitelist-Validierung gegen CLI-Injection if !service.chars().all(|c| c.is_alphanumeric() || c == '.' || c == '_' || c == '-') { return Err("Ungültiger Service-Name".to_string()); } if !path.chars().all(|c| c.is_alphanumeric() || c == '/' || c == '.' || c == '_' || c == '-') { return Err("Ungültiger D-Bus Pfad".to_string()); } if !method.chars().all(|c| c.is_alphanumeric() || c == '.' || c == '_' || c == '-') { return Err("Ungültiger Methodenname".to_string()); } let mut cmd = Command::new("dbus-send"); cmd.arg("--print-reply") .arg(bus) .arg(format!("--dest={}", service)) .arg(&path) .arg(&method); if let Some(args) = args { for a in args { cmd.arg(a); } } let output = cmd.output().map_err(|e| format!("dbus-send Fehler: {}", e))?; Ok(DbusCallResult { success: output.status.success(), stdout: String::from_utf8_lossy(&output.stdout).to_string(), stderr: String::from_utf8_lossy(&output.stderr).to_string(), exit_code: output.status.code().unwrap_or(-1), }) } #[tauri::command] pub async fn dbus_list_services(session: Option) -> Result, String> { let bus = if session.unwrap_or(true) { "--session" } else { "--system" }; let output = Command::new("dbus-send") .arg("--print-reply") .arg(bus) .arg("--dest=org.freedesktop.DBus") .arg("/org/freedesktop/DBus") .arg("org.freedesktop.DBus.ListNames") .output() .map_err(|e| format!("dbus-send ListNames: {}", e))?; if !output.status.success() { return Err(String::from_utf8_lossy(&output.stderr).to_string()); } let text = String::from_utf8_lossy(&output.stdout); let mut services: Vec = text .lines() .filter_map(|l| { let t = l.trim(); if t.starts_with("string \"") && t.ends_with('"') { Some(t[8..t.len() - 1].to_string()) } else { None } }) .filter(|s| !s.starts_with(':')) .collect(); services.sort(); services.dedup(); Ok(services) } // ============ D-Bus Vordefinierte Aktionen ============ /// Vordefinierte Desktop-Aktion #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DesktopAction { pub id: String, pub name: String, pub icon: String, pub category: String, pub description: String, } /// Liste aller verfuegbaren Desktop-Aktionen #[tauri::command] pub async fn dbus_list_actions() -> Result, String> { Ok(vec![ DesktopAction { id: "dolphin".into(), name: "Dolphin öffnen".into(), icon: "📁".into(), category: "Dateimanager".into(), description: "Dolphin Dateimanager im aktuellen Verzeichnis öffnen".into(), }, DesktopAction { id: "dolphin_path".into(), name: "Dolphin (Pfad)".into(), icon: "📂".into(), category: "Dateimanager".into(), description: "Dolphin in einem bestimmten Verzeichnis öffnen".into(), }, DesktopAction { id: "kate".into(), name: "Kate öffnen".into(), icon: "📝".into(), category: "Editor".into(), description: "Kate Texteditor starten".into(), }, DesktopAction { id: "kate_file".into(), name: "Kate (Datei)".into(), icon: "📄".into(), category: "Editor".into(), description: "Datei in Kate öffnen".into(), }, DesktopAction { id: "notify".into(), name: "Benachrichtigung".into(), icon: "🔔".into(), category: "System".into(), description: "KDE-Desktop-Notification senden".into(), }, DesktopAction { id: "konsole".into(), name: "Konsole öffnen".into(), icon: "🖥️".into(), category: "Terminal".into(), description: "Konsole Terminal-Emulator starten".into(), }, DesktopAction { id: "konsole_cmd".into(), name: "Konsole (Befehl)".into(), icon: "⚡".into(), category: "Terminal".into(), description: "Befehl in Konsole ausführen".into(), }, DesktopAction { id: "firefox".into(), name: "Firefox öffnen".into(), icon: "🌐".into(), category: "Browser".into(), description: "Firefox-Browser starten".into(), }, DesktopAction { id: "firefox_url".into(), name: "Firefox (URL)".into(), icon: "🔗".into(), category: "Browser".into(), description: "URL in Firefox öffnen".into(), }, DesktopAction { id: "lock_screen".into(), name: "Bildschirm sperren".into(), icon: "🔒".into(), category: "System".into(), description: "KDE Bildschirmsperre aktivieren".into(), }, ]) } /// Fuehrt eine vordefinierte Desktop-Aktion aus #[tauri::command] pub async fn dbus_run_action( action_id: String, arg: Option, ) -> Result { match action_id.as_str() { "dolphin" => { let dir = arg.unwrap_or_else(|| ".".into()); run_desktop_cmd("dolphin", &[&dir]) } "dolphin_path" => { let path = arg.ok_or("Pfad-Argument fehlt")?; run_desktop_cmd("dolphin", &[&path]) } "kate" => { run_desktop_cmd("kate", &[]) } "kate_file" => { let file = arg.ok_or("Datei-Argument fehlt")?; run_desktop_cmd("kate", &[&file]) } "notify" => { let msg = arg.unwrap_or_else(|| "Claude Desktop Benachrichtigung".into()); // KDE notify-send run_desktop_cmd("notify-send", &["Claude Desktop", &msg]) } "konsole" => { run_desktop_cmd("konsole", &[]) } "konsole_cmd" => { let cmd = arg.ok_or("Befehl-Argument fehlt")?; run_desktop_cmd("konsole", &["-e", &cmd]) } "firefox" => { run_desktop_cmd("firefox", &[]) } "firefox_url" => { let url = arg.ok_or("URL-Argument fehlt")?; // Grundlegende URL-Validierung if !url.starts_with("http://") && !url.starts_with("https://") { return Err("URL muss mit http:// oder https:// beginnen".into()); } run_desktop_cmd("firefox", &[&url]) } "lock_screen" => { // loginctl lock-session run_desktop_cmd("loginctl", &["lock-session"]) } _ => Err(format!("Unbekannte Aktion: {}", action_id)), } } /// Hilfsfunktion: Desktop-Programm starten fn run_desktop_cmd(program: &str, args: &[&str]) -> Result { // Programm im Hintergrund starten (nicht blockierend) let mut cmd = Command::new(program); cmd.args(args); // Bei GUI-Apps: spawn (nicht blockieren), bei CLI-Tools: output (Ergebnis lesen) let is_cli = matches!(program, "notify-send" | "loginctl" | "dbus-send"); if is_cli { let output = cmd.output().map_err(|e| format!("{} Fehler: {}", program, e))?; Ok(DbusCallResult { success: output.status.success(), stdout: String::from_utf8_lossy(&output.stdout).to_string(), stderr: String::from_utf8_lossy(&output.stderr).to_string(), exit_code: output.status.code().unwrap_or(-1), }) } else { // GUI-App im Hintergrund starten cmd.spawn().map_err(|e| format!("{} konnte nicht gestartet werden: {}", program, e))?; Ok(DbusCallResult { success: true, stdout: format!("{} gestartet", program), stderr: String::new(), exit_code: 0, }) } } // ============ Xvfb (Virtuelles Display) ============ #[derive(Debug, Clone, Serialize, Deserialize)] pub struct XvfbStatus { pub running: bool, pub display_num: u16, pub pid: Option, pub resolution: String, } static XVFB_STATE: std::sync::OnceLock> = std::sync::OnceLock::new(); fn xvfb_state() -> &'static std::sync::Mutex { XVFB_STATE.get_or_init(|| { std::sync::Mutex::new(XvfbStatus { running: false, display_num: 1, pid: None, resolution: "1920x1080x24".into(), }) }) } #[tauri::command] pub async fn xvfb_start(display_num: Option, resolution: Option) -> Result { let display = display_num.unwrap_or(1); let res = resolution.unwrap_or_else(|| "1920x1080x24".into()); // Pruefen ob Xvfb verfuegbar let check = Command::new("which").arg("Xvfb").output(); if check.map(|o| !o.status.success()).unwrap_or(true) { return Err("Xvfb nicht installiert. Auf NixOS: nixpkgs.xorg.xvfb".into()); } let child = Command::new("Xvfb") .arg(format!(":{}", display)) .arg("-screen") .arg("0") .arg(&res) .spawn() .map_err(|e| format!("Xvfb-Start fehlgeschlagen: {}", e))?; let status = XvfbStatus { running: true, display_num: display, pid: Some(child.id()), resolution: res, }; *xvfb_state().lock().unwrap() = status.clone(); Ok(status) } #[tauri::command] pub async fn xvfb_stop() -> Result { let mut state = xvfb_state().lock().unwrap(); if let Some(pid) = state.pid { let _ = Command::new("kill").arg(pid.to_string()).output(); } state.running = false; state.pid = None; Ok(state.clone()) } #[tauri::command] pub async fn xvfb_status() -> Result { Ok(xvfb_state().lock().unwrap().clone()) } #[tauri::command] pub async fn xvfb_screenshot(display_num: Option) -> Result { // Screenshot vom virtuellen Display — probiert mehrere Tools durch let display = display_num.unwrap_or(1); let display_env = format!(":{}", display); let tmp = std::env::temp_dir().join(format!("claude-xvfb-{}.png", uuid::Uuid::new_v4())); // Versuchte Kommandos in Reihenfolge let attempts: Vec<(&str, Vec)> = vec![ ("scrot", vec!["-q".into(), "80".into(), tmp.to_string_lossy().into_owned()]), ("import", vec!["-window".into(), "root".into(), tmp.to_string_lossy().into_owned()]), ("ffmpeg", vec![ "-f".into(), "x11grab".into(), "-i".into(), display_env.clone(), "-frames:v".into(), "1".into(), "-y".into(), tmp.to_string_lossy().into_owned(), ]), ]; let mut last_err = String::new(); for (cmd, args) in &attempts { // Tool vorhanden? if Command::new("which").arg(cmd).output().map(|o| !o.status.success()).unwrap_or(true) { last_err = format!("'{}' nicht installiert", cmd); continue; } let result = Command::new(cmd) .env("DISPLAY", &display_env) .args(args) .output(); match result { Ok(o) if o.status.success() && tmp.exists() => { let bytes = std::fs::read(&tmp).map_err(|e| e.to_string())?; let _ = std::fs::remove_file(&tmp); use base64::Engine; return Ok(base64::engine::general_purpose::STANDARD.encode(&bytes)); } Ok(o) => { last_err = format!("{} Exit {}: {}", cmd, o.status, String::from_utf8_lossy(&o.stderr)); } Err(e) => { last_err = format!("{} Fehler: {}", cmd, e); } } } Err(format!( "Screenshot fehlgeschlagen. Installiere eines: scrot / imagemagick / ffmpeg.\nLetzter Fehler: {}", last_err )) } // ============ Screenshot-Analyse ============ /// Macht einen Screenshot (Bereich oder Vollbild) und gibt Base64-PNG zurueck #[tauri::command] pub async fn capture_screenshot( region: Option, ) -> Result { let tmp = std::env::temp_dir().join(format!("claude-screenshot-{}.png", uuid::Uuid::new_v4())); let tmp_path = tmp.to_string_lossy().to_string(); let want_region = region.unwrap_or(true); // Versuch 1: spectacle (KDE) if Command::new("which").arg("spectacle").output().map(|o| o.status.success()).unwrap_or(false) { let mut args = vec![ "-b".to_string(), // Hintergrund (kein GUI) "-n".to_string(), // Nicht interaktiv speichern "-o".to_string(), tmp_path.clone(), ]; if want_region { args.push("-r".to_string()); // Region auswählen } let result = Command::new("spectacle") .args(&args) .output() .map_err(|e| format!("spectacle Fehler: {}", e))?; if result.status.success() && tmp.exists() { let bytes = std::fs::read(&tmp).map_err(|e| e.to_string())?; let _ = std::fs::remove_file(&tmp); if bytes.is_empty() { return Err("Screenshot abgebrochen (keine Auswahl)".into()); } use base64::Engine; return Ok(base64::engine::general_purpose::STANDARD.encode(&bytes)); } } // Versuch 2: scrot (einfacher, aber kein interaktiver Bereich ohne extra Tools) if Command::new("which").arg("scrot").output().map(|o| o.status.success()).unwrap_or(false) { let mut args = vec![tmp_path.clone()]; if want_region { args.insert(0, "-s".to_string()); // Select-Modus } let result = Command::new("scrot") .args(&args) .output() .map_err(|e| format!("scrot Fehler: {}", e))?; if result.status.success() && tmp.exists() { let bytes = std::fs::read(&tmp).map_err(|e| e.to_string())?; let _ = std::fs::remove_file(&tmp); use base64::Engine; return Ok(base64::engine::general_purpose::STANDARD.encode(&bytes)); } } // Versuch 3: gnome-screenshot if Command::new("which").arg("gnome-screenshot").output().map(|o| o.status.success()).unwrap_or(false) { let mut cmd = Command::new("gnome-screenshot"); cmd.arg("-f").arg(&tmp_path); if want_region { cmd.arg("-a"); // Area } let result = cmd.output().map_err(|e| format!("gnome-screenshot Fehler: {}", e))?; if result.status.success() && tmp.exists() { let bytes = std::fs::read(&tmp).map_err(|e| e.to_string())?; let _ = std::fs::remove_file(&tmp); use base64::Engine; return Ok(base64::engine::general_purpose::STANDARD.encode(&bytes)); } } Err("Kein Screenshot-Tool gefunden. Installiere spectacle (KDE), scrot oder gnome-screenshot.".into()) } // ============ Playwright-Infos ============ #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlaywrightInfo { pub available: bool, pub hint: String, } #[tauri::command] pub async fn playwright_info() -> Result { // Playwright laeuft bei Eddy ueber MCP — hier nur Info fuer das UI Ok(PlaywrightInfo { available: true, hint: "Playwright-Steuerung laeuft ueber den MCP-Server. Nutze die Tools \ mcp__plugin_playwright_playwright__browser_navigate, \ _click, _snapshot etc. im Chat. Für Dolibarr-Automation \ empfohlen: Session-Login via MCP-Playwright speichern." .into(), }) }