All checks were successful
Build AppImage / build (push) Has been skipped
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>
241 lines
7.6 KiB
Rust
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(),
|
|
})
|
|
}
|