All checks were successful
Build AppImage / build (push) Successful in 8m16s
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>
472 lines
16 KiB
Rust
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(),
|
|
})
|
|
}
|