claude-desktop/src-tauri/src/programs.rs
Eddy 3993387977
All checks were successful
Build AppImage / build (push) Has been skipped
Security-Fixes + UI-Verbesserungen: Stop-Button, Textfeld, Agent-Filter
Backend:
- Credentials aus Code entfernt → ENV-Variablen mit Fallback
- File-Traversal in Update-Download verhindert (Path-Sanitization)
- CLI-Injection bei D-Bus mit Whitelist-Validierung abgesichert

Frontend:
- Stop-Button dezenter (kleinere Schrift, gedämpftes Rot, kein Pulsieren)
- Stop löscht keine Session/Messages mehr — nur Agents stoppen
- Textfeld nicht mehr blockiert während Claude arbeitet (Einwände möglich)
- Agent-Filter "Nur aktive" wird in localStorage persistent gespeichert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 03:18:39 +02:00

241 lines
7.6 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)
}
// ============ 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
))
}
// ============ 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(),
})
}