claude-desktop/src-tauri/src/programs.rs
Eddy d4c57b777a
All checks were successful
Build AppImage / build (push) Successful in 8m16s
feat: Guard-Rails UI, D-Bus Aktionen, Screenshot-Analyse [appimage]
Guard-Rails: Live-Feed mit Risiko-Badges, 3 Tabs, Ein-Klick-Freigabe
D-Bus: 10 Desktop-Aktionen (Dolphin, Kate, Konsole, Firefox, Notify)
Screenshot: Region/Vollbild via spectacle/scrot, Vorschau + Chat-Send

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 12:02:24 +02:00

472 lines
16 KiB
Rust

// 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<Vec<String>>,
session: Option<bool>,
) -> Result<DbusCallResult, String> {
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<bool>) -> Result<Vec<String>, 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<String> = 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<Vec<DesktopAction>, 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<String>,
) -> Result<DbusCallResult, String> {
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<DbusCallResult, String> {
// 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<u32>,
pub resolution: String,
}
static XVFB_STATE: std::sync::OnceLock<std::sync::Mutex<XvfbStatus>> = std::sync::OnceLock::new();
fn xvfb_state() -> &'static std::sync::Mutex<XvfbStatus> {
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<u16>, resolution: Option<String>) -> Result<XvfbStatus, String> {
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<XvfbStatus, String> {
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<XvfbStatus, String> {
Ok(xvfb_state().lock().unwrap().clone())
}
#[tauri::command]
pub async fn xvfb_screenshot(display_num: Option<u16>) -> Result<String, String> {
// 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<String>)> = 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<bool>,
) -> Result<String, String> {
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<PlaywrightInfo, String> {
// 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(),
})
}