feat: Guard-Rails UI, D-Bus Aktionen, Screenshot-Analyse [appimage]
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>
This commit is contained in:
Eddy 2026-04-21 12:02:24 +02:00
parent 87ba8f7bdf
commit d4c57b777a
7 changed files with 954 additions and 131 deletions

View file

@ -9,6 +9,9 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## [Unreleased] - 2026-04-21 ## [Unreleased] - 2026-04-21
### Hinzugefügt ### Hinzugefügt
- **Guard-Rails UI (Live)**: 3-Tab-Ansicht (Live-Feed/Regeln/Blockiert), Risiko-Statistik-Leiste, Ein-Klick-Freigabe bei Bestätigungsbedarf, guard-check Events vom Backend (`GuardRailsPanel.svelte`, `guard.rs`)
- **D-Bus Desktop-Aktionen**: 10 vordefinierte Aktionen (Dolphin, Kate, Konsole, Firefox, Notify, Lock Screen), Aktionen-Grid im ProgramsPanel, CLI/GUI-Unterscheidung (`programs.rs`, `ProgramsPanel.svelte`)
- **Screenshot-Analyse**: Bildschirmbereich oder Vollbild capturen via spectacle/scrot/gnome-screenshot, Vorschau im Panel, "An Claude senden" Button (`programs.rs`, `ProgramsPanel.svelte`)
- **Projekt-Wechsel**: Ein-Klick-Projektwechsel in der Sidebar — Dropdown mit Projektliste, Hinzufügen/Entfernen, Working-Dir + Sticky-Context wird automatisch umgeschaltet (`SessionList.svelte`, `db.rs`) - **Projekt-Wechsel**: Ein-Klick-Projektwechsel in der Sidebar — Dropdown mit Projektliste, Hinzufügen/Entfernen, Working-Dir + Sticky-Context wird automatisch umgeschaltet (`SessionList.svelte`, `db.rs`)
- **File-Drop auf Chat**: Dateien per Drag & Drop auf den Chat ziehen — Text-Dateien als Code-Block, Bilder als Base64, Spracherkennung, 500KB-Limit (`ChatPanel.svelte`) - **File-Drop auf Chat**: Dateien per Drag & Drop auf den Chat ziehen — Text-Dateien als Code-Block, Bilder als Base64, Spracherkennung, 500KB-Limit (`ChatPanel.svelte`)
- **Persistent Memory**: Auto-Load Memory-Einträge werden bei jeder Nachricht in den Claude-Context injiziert — Cross-Session Gedächtnis für Patterns, Zugänge, Präferenzen (`memory.rs`, `claude.rs`) - **Persistent Memory**: Auto-Load Memory-Einträge werden bei jeder Nachricht in den Claude-Context injiziert — Cross-Session Gedächtnis für Patterns, Zugänge, Präferenzen (`memory.rs`, `claude.rs`)

View file

@ -62,7 +62,7 @@ Alles aus Phase 1-16 ist implementiert und funktionsfaehig:
|---------|-----------|--------| |---------|-----------|--------|
| ✅ Projekt-Wechsel | `db.rs`, `SessionList.svelte` | Ein Klick wechselt Projekt (CWD, Context, KB-Filter) | | ✅ Projekt-Wechsel | `db.rs`, `SessionList.svelte` | Ein Klick wechselt Projekt (CWD, Context, KB-Filter) |
| MCP-Hub nativ | `claude-bridge.js` | ⬜ Alle MCP-Server direkt nutzbar (Docker, Forgejo, DB) ohne CLI-Umweg | | MCP-Hub nativ | `claude-bridge.js` | ⬜ Alle MCP-Server direkt nutzbar (Docker, Forgejo, DB) ohne CLI-Umweg |
| Guard-Rails UI | `guard.rs`, `GuardPanel.svelte` | ⬜ Live-Anzeige was Claude darf/nicht darf, Ein-Klick-Freigabe | | ✅ Guard-Rails UI | `guard.rs`, `GuardRailsPanel.svelte` | Live-Feed, Risiko-Statistik, Ein-Klick-Freigabe, 3 Tabs |
| ✅ Persistent Memory | `memory.rs`, `claude.rs` | Auto-Load Eintraege in Context, Cross-Session Gedaechtnis | | ✅ Persistent Memory | `memory.rs`, `claude.rs` | Auto-Load Eintraege in Context, Cross-Session Gedaechtnis |
| ✅ Quick-Actions | `QuickActions.svelte`, `ChatPanel.svelte` | Ctrl+K Palette: Deploy, Build, Test, Commit, Git, Navigation | | ✅ Quick-Actions | `QuickActions.svelte`, `ChatPanel.svelte` | Ctrl+K Palette: Deploy, Build, Test, Commit, Git, Navigation |
| ✅ Voice-Conversation | `voice.rs`, `VoicePanel.svelte` | Lokales Whisper STT + Piper TTS, VAD, Gespraechsmodus | | ✅ Voice-Conversation | `voice.rs`, `VoicePanel.svelte` | Lokales Whisper STT + Piper TTS, VAD, Gespraechsmodus |
@ -91,10 +91,10 @@ Alles aus Phase 1-16 ist implementiert und funktionsfaehig:
| Feature | Datei(en) | Beschreibung | | Feature | Datei(en) | Beschreibung |
|---------|-----------|--------------| |---------|-----------|--------------|
| D-Bus Actions | `programs.rs` | ⬜ Vordefinierte Aktionen: Dolphin oeffnen, Kate starten, Notifications | | ✅ D-Bus Actions | `programs.rs`, `ProgramsPanel.svelte` | 10 Aktionen: Dolphin, Kate, Konsole, Firefox, Notify, Lock |
| ✅ Clipboard-Watch | `clipboard.rs` | Claude reagiert auf Clipboard-Inhalt (Code/URL/Fehler erkennen) | | ✅ Clipboard-Watch | `clipboard.rs` | Claude reagiert auf Clipboard-Inhalt (Code/URL/Fehler erkennen) |
| ✅ File-Drop | `ChatPanel.svelte` | Dateien auf Chat droppen → Claude analysiert/bearbeitet | | ✅ File-Drop | `ChatPanel.svelte` | Dateien auf Chat droppen → Claude analysiert/bearbeitet |
| Screenshot-Analyse | `programs.rs` | ⬜ Bildschirmbereich markieren → Claude beschreibt/debuggt UI | | ✅ Screenshot-Analyse | `programs.rs`, `ProgramsPanel.svelte` | Spectacle/Scrot Region-Capture, Vorschau, an Chat senden |
| ✅ Global Hotkey | `lib.rs` | Super+C oeffnet Claude-Eingabe von ueberall | | ✅ Global Hotkey | `lib.rs` | Super+C oeffnet Claude-Eingabe von ueberall |
--- ---

View file

@ -2,7 +2,7 @@
// Risiko-Klassifikation und Freigabe-Management // Risiko-Klassifikation und Freigabe-Management
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Emitter, Manager};
/// Risiko-Level einer Aktion /// Risiko-Level einer Aktion
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@ -354,8 +354,14 @@ pub async fn check_action(
let risk = guard.classify_risk(&tool, &command, path.as_deref()); let risk = guard.classify_risk(&tool, &command, path.as_deref());
let ts = chrono::Utc::now().timestamp_millis();
// Wenn blockiert, sofort ablehnen // Wenn blockiert, sofort ablehnen
if risk == RiskLevel::Blocked { if risk == RiskLevel::Blocked {
let _ = app.emit("guard-check", serde_json::json!({
"tool": tool, "command": command, "risk": "blocked",
"allowed": false, "needs_confirmation": false, "timestamp": ts
}));
return Ok(serde_json::json!({ return Ok(serde_json::json!({
"allowed": false, "allowed": false,
"risk": "blocked", "risk": "blocked",
@ -365,9 +371,15 @@ pub async fn check_action(
// Permission prüfen // Permission prüfen
if let Some(perm) = guard.check_permission(&tool, &command, path.as_deref()) { if let Some(perm) = guard.check_permission(&tool, &command, path.as_deref()) {
let perm_allowed = perm.action == PermissionAction::Allow;
let risk_str = format!("{:?}", risk).to_lowercase();
let _ = app.emit("guard-check", serde_json::json!({
"tool": tool, "command": command, "risk": risk_str,
"allowed": perm_allowed, "needs_confirmation": false, "timestamp": ts
}));
return Ok(serde_json::json!({ return Ok(serde_json::json!({
"allowed": perm.action == PermissionAction::Allow, "allowed": perm_allowed,
"risk": format!("{:?}", risk).to_lowercase(), "risk": risk_str,
"matched_rule": perm.id "matched_rule": perm.id
})); }));
} }
@ -375,10 +387,24 @@ pub async fn check_action(
// Kein Match - Frontend muss fragen // Kein Match - Frontend muss fragen
let suggested = guard.suggest_pattern(&tool, &command); let suggested = guard.suggest_pattern(&tool, &command);
let risk_str = format!("{:?}", risk).to_lowercase();
let allowed = risk == RiskLevel::Safe;
let needs_confirm = risk != RiskLevel::Safe;
// Live-Event ans Frontend senden
let _ = app.emit("guard-check", serde_json::json!({
"tool": tool,
"command": command,
"risk": risk_str,
"allowed": allowed,
"needs_confirmation": needs_confirm,
"timestamp": chrono::Utc::now().timestamp_millis()
}));
Ok(serde_json::json!({ Ok(serde_json::json!({
"allowed": risk == RiskLevel::Safe, "allowed": allowed,
"risk": format!("{:?}", risk).to_lowercase(), "risk": risk_str,
"needs_confirmation": risk != RiskLevel::Safe, "needs_confirmation": needs_confirm,
"suggested_pattern": suggested "suggested_pattern": suggested
})) }))
} }

View file

@ -155,6 +155,9 @@ pub fn run() {
// Programm-Steuerung (D-Bus, Xvfb, Playwright-Info) // Programm-Steuerung (D-Bus, Xvfb, Playwright-Info)
programs::dbus_call, programs::dbus_call,
programs::dbus_list_services, programs::dbus_list_services,
programs::dbus_list_actions,
programs::dbus_run_action,
programs::capture_screenshot,
programs::xvfb_start, programs::xvfb_start,
programs::xvfb_stop, programs::xvfb_stop,
programs::xvfb_status, programs::xvfb_status,

View file

@ -95,6 +95,157 @@ pub async fn dbus_list_services(session: Option<bool>) -> Result<Vec<String>, St
Ok(services) 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) ============ // ============ Xvfb (Virtuelles Display) ============
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -219,6 +370,86 @@ pub async fn xvfb_screenshot(display_num: Option<u16>) -> Result<String, String>
)) ))
} }
// ============ 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 ============ // ============ Playwright-Infos ============
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
interface Permission { interface Permission {
id: string; id: string;
@ -14,29 +15,61 @@
last_used: string | null; last_used: string | null;
} }
let permissions: Permission[] = []; interface GuardCheck {
let blockedPatterns: string[] = []; tool: string;
let loading = true; command: string;
let showAddForm = false; risk: string;
allowed: boolean;
needs_confirmation: boolean;
timestamp: number;
}
let permissions = $state<Permission[]>([]);
let blockedPatterns = $state<string[]>([]);
let recentChecks = $state<GuardCheck[]>([]);
let loading = $state(true);
let showAddForm = $state(false);
let activeTab = $state<'live' | 'rules' | 'blocked'>('live');
// Formular-State // Formular-State
let newPattern = ''; let newPattern = $state('');
let newTool = ''; let newTool = $state('');
let newAction: 'allow' | 'deny' = 'allow'; let newAction = $state<'allow' | 'deny'>('allow');
let newType: 'session' | 'permanent' = 'permanent'; let newType = $state<'session' | 'permanent'>('permanent');
let testResult = $state<string | null>(null);
// Statistiken
let stats = $derived({
total: recentChecks.length,
safe: recentChecks.filter(c => c.risk === 'safe').length,
moderate: recentChecks.filter(c => c.risk === 'moderate').length,
critical: recentChecks.filter(c => c.risk === 'critical').length,
blocked: recentChecks.filter(c => c.risk === 'blocked').length,
});
let unlistenGuard: (() => void) | null = null;
async function loadData() { async function loadData() {
try { try {
permissions = await invoke('get_permissions'); permissions = await invoke('get_permissions');
blockedPatterns = await invoke('get_blocked_patterns'); blockedPatterns = await invoke('get_blocked_patterns');
} catch (err) { } catch (err) {
console.error('Fehler beim Laden:', err); console.error('Guard-Rails Ladefehler:', err);
} }
loading = false; loading = false;
} }
onMount(() => { onMount(async () => {
loadData(); await loadData();
// Live Guard-Check Events empfangen
unlistenGuard = await listen<GuardCheck>('guard-check', (event) => {
recentChecks = [event.payload, ...recentChecks.slice(0, 49)];
});
});
onDestroy(() => {
unlistenGuard?.();
}); });
async function addPermission() { async function addPermission() {
@ -52,9 +85,10 @@
newPattern = ''; newPattern = '';
newTool = ''; newTool = '';
showAddForm = false; showAddForm = false;
testResult = null;
await loadData(); await loadData();
} catch (err) { } catch (err) {
console.error('Fehler:', err); console.error('Permission-Fehler:', err);
} }
} }
@ -63,34 +97,136 @@
await invoke('remove_permission', { id }); await invoke('remove_permission', { id });
await loadData(); await loadData();
} catch (err) { } catch (err) {
console.error('Fehler:', err); console.error('Lösch-Fehler:', err);
}
}
async function quickAllow(check: GuardCheck) {
try {
await invoke('add_permission', {
pattern: check.command,
tool: check.tool || null,
pathPattern: null,
permissionType: 'session',
action: 'allow',
});
await loadData();
} catch (err) {
console.error('Quick-Allow Fehler:', err);
} }
} }
async function checkAction() { async function checkAction() {
if (!newPattern.trim()) return; if (!newPattern.trim()) return;
try { try {
const result = await invoke('check_action', { const result: any = await invoke('check_action', {
tool: newTool || 'Bash', tool: newTool || 'Bash',
command: newPattern, command: newPattern,
path: null, path: null,
}); });
console.log('Check-Ergebnis:', result); testResult = `${riskEmoji(result.risk)} ${result.risk.toUpperCase()} — ${result.allowed ? 'Erlaubt' : result.needs_confirmation ? 'Bestätigung nötig' : 'Blockiert'}`;
} catch (err) { } catch (err) {
console.error('Fehler:', err); testResult = `Fehler: ${err}`;
} }
} }
function riskEmoji(risk: string): string {
switch (risk) {
case 'safe': return '🟢';
case 'moderate': return '🟡';
case 'critical': return '🔴';
case 'blocked': return '⛔';
default: return '⚪';
}
}
function riskClass(risk: string): string {
switch (risk) {
case 'safe': return 'risk-safe';
case 'moderate': return 'risk-moderate';
case 'critical': return 'risk-critical';
case 'blocked': return 'risk-blocked';
default: return '';
}
}
function formatTime(ts: number): string {
const d = new Date(ts);
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function truncateCmd(cmd: string, max: number = 60): string {
return cmd.length > max ? cmd.slice(0, max) + '...' : cmd;
}
</script> </script>
<div class="guard-panel"> <div class="guard-panel">
<!-- Header mit Statistik-Leiste -->
<div class="panel-header"> <div class="panel-header">
<h2>🛡️ Guard-Rails</h2> <h2>🛡️ Guard-Rails</h2>
<button class="btn-add" on:click={() => showAddForm = !showAddForm}> <div class="stats-bar">
{showAddForm ? '✕' : '+ Regel'} {#if stats.total > 0}
<span class="stat safe" title="Safe">{stats.safe}</span>
<span class="stat moderate" title="Moderate">{stats.moderate}</span>
<span class="stat critical" title="Critical">{stats.critical}</span>
{#if stats.blocked > 0}
<span class="stat blocked" title="Blocked">{stats.blocked}</span>
{/if}
{/if}
</div>
</div>
<!-- Tab-Navigation -->
<div class="tabs">
<button class="tab" class:active={activeTab === 'live'} onclick={() => activeTab = 'live'}>
Live ({recentChecks.length})
</button>
<button class="tab" class:active={activeTab === 'rules'} onclick={() => activeTab = 'rules'}>
Regeln ({permissions.length})
</button>
<button class="tab" class:active={activeTab === 'blocked'} onclick={() => activeTab = 'blocked'}>
Blockiert ({blockedPatterns.length})
</button> </button>
</div> </div>
<!-- Neue Regel hinzufügen --> {#if loading}
<div class="loading-state">Lade Guard-Rails...</div>
{:else if activeTab === 'live'}
<!-- Live Tool-Checks -->
<div class="live-feed">
{#if recentChecks.length === 0}
<div class="empty-hint">
<span class="empty-icon">📡</span>
<span>Warte auf Tool-Nutzung...</span>
<span class="empty-sub">Hier erscheinen Live-Checks wenn Claude Tools verwendet</span>
</div>
{:else}
{#each recentChecks as check, i}
<div class="check-item {riskClass(check.risk)}">
<div class="check-header">
<span class="check-risk">{riskEmoji(check.risk)}</span>
<span class="check-tool">{check.tool}</span>
<span class="check-time">{formatTime(check.timestamp)}</span>
</div>
<code class="check-cmd">{truncateCmd(check.command)}</code>
{#if check.needs_confirmation && !check.allowed}
<button class="btn-quick-allow" onclick={() => quickAllow(check)}>
✅ Erlauben (Session)
</button>
{/if}
</div>
{/each}
{/if}
</div>
{:else if activeTab === 'rules'}
<!-- Regeln verwalten -->
<div class="rules-section">
<button class="btn-add" onclick={() => showAddForm = !showAddForm}>
{showAddForm ? '✕ Abbrechen' : '+ Neue Regel'}
</button>
{#if showAddForm} {#if showAddForm}
<div class="add-form"> <div class="add-form">
<div class="form-row"> <div class="form-row">
@ -116,52 +252,62 @@
</select> </select>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button class="btn-save" on:click={addPermission}>Speichern</button> <button class="btn-save" onclick={addPermission}>Speichern</button>
<button class="btn-test" on:click={checkAction}>Testen</button> <button class="btn-test" onclick={checkAction}>Testen</button>
</div> </div>
{#if testResult}
<div class="test-result">{testResult}</div>
{/if}
</div> </div>
{/if} {/if}
{#if loading}
<div class="loading-state">Lade...</div>
{:else}
<!-- Aktive Regeln -->
<div class="section">
<h3>Aktive Regeln ({permissions.length})</h3>
{#if permissions.length === 0} {#if permissions.length === 0}
<div class="empty-hint">Keine Regeln definiert</div> <div class="empty-hint">
<span>Keine Regeln definiert</span>
<span class="empty-sub">Regeln werden bei Tool-Nutzung oder manuell erstellt</span>
</div>
{:else} {:else}
<div class="rules-list"> <div class="rules-list">
{#each permissions as perm} {#each permissions as perm}
<div class="rule-item" class:deny={perm.action === 'Deny'}> <div class="rule-item" class:deny={perm.action === 'Deny'}>
<div class="rule-main"> <div class="rule-main">
<span class="rule-action" class:allow={perm.action === 'Allow'} class:deny={perm.action === 'Deny'}> <span class="rule-action">
{perm.action === 'Allow' ? '✅' : '🚫'} {perm.action === 'Allow' ? '✅' : '🚫'}
</span> </span>
<div class="rule-info">
<code class="rule-pattern">{perm.pattern}</code> <code class="rule-pattern">{perm.pattern}</code>
<div class="rule-tags">
{#if perm.tool} {#if perm.tool}
<span class="rule-tool">{perm.tool}</span> <span class="tag tool">{perm.tool}</span>
{/if}
<span class="tag type">
{perm.permission_type === 'Permanent' ? '💾 Dauerhaft' : '⏱️ Session'}
</span>
{#if perm.use_count > 0}
<span class="tag count">{perm.use_count}x genutzt</span>
{/if} {/if}
</div> </div>
<div class="rule-meta">
<span class="rule-type">
{perm.permission_type === 'Permanent' ? '💾' : '⏱️'}
</span>
<span class="rule-count">{perm.use_count}x</span>
<button class="btn-delete" on:click={() => removePermission(perm.id)}>🗑️</button>
</div> </div>
</div> </div>
<button class="btn-delete" onclick={() => removePermission(perm.id)} title="Regel löschen">
🗑️
</button>
</div>
{/each} {/each}
</div> </div>
{/if} {/if}
</div> </div>
{:else if activeTab === 'blocked'}
<!-- Blockierte Patterns --> <!-- Blockierte Patterns -->
<div class="section"> <div class="blocked-section">
<h3>🚫 Immer blockiert ({blockedPatterns.length})</h3> <div class="blocked-info">
Diese Befehle sind permanent blockiert und können nicht freigegeben werden.
</div>
<div class="blocked-list"> <div class="blocked-list">
{#each blockedPatterns as pattern} {#each blockedPatterns as pattern}
<div class="blocked-item"> <div class="blocked-item">
<span class="blocked-icon"></span>
<code>{pattern}</code> <code>{pattern}</code>
</div> </div>
{/each} {/each}
@ -183,12 +329,168 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: var(--spacing-md); margin-bottom: var(--spacing-sm);
} }
.panel-header h2 { .panel-header h2 {
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
margin: 0;
}
/* Statistik-Leiste */
.stats-bar {
display: flex;
gap: 4px;
}
.stat {
font-size: 0.65rem;
font-weight: 700;
padding: 1px 6px;
border-radius: 8px;
min-width: 20px;
text-align: center;
}
.stat.safe { background: rgba(34, 197, 94, 0.2); color: #22c55e; }
.stat.moderate { background: rgba(234, 179, 8, 0.2); color: #eab308; }
.stat.critical { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
.stat.blocked { background: rgba(139, 92, 246, 0.2); color: #8b5cf6; }
/* Tabs */
.tabs {
display: flex;
gap: 2px;
margin-bottom: var(--spacing-md);
background: var(--bg-secondary);
border-radius: var(--radius-md);
padding: 2px;
}
.tab {
flex: 1;
padding: var(--spacing-xs) var(--spacing-sm);
font-size: 0.7rem;
font-weight: 500;
border-radius: var(--radius-sm);
color: var(--text-secondary);
transition: all 0.2s;
}
.tab.active {
background: var(--bg-tertiary);
color: var(--text-primary);
font-weight: 600;
}
.tab:hover:not(.active) {
color: var(--text-primary);
}
/* Loading & Empty */
.loading-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-size: 0.8rem;
}
.empty-hint {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-lg);
color: var(--text-secondary);
font-size: 0.8rem;
text-align: center;
}
.empty-icon {
font-size: 1.5rem;
}
.empty-sub {
font-size: 0.65rem;
opacity: 0.7;
}
/* Live-Feed */
.live-feed {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
overflow-y: auto;
}
.check-item {
padding: var(--spacing-sm);
background: var(--bg-secondary);
border-radius: var(--radius-sm);
border-left: 3px solid var(--text-secondary);
}
.check-item.risk-safe { border-left-color: #22c55e; }
.check-item.risk-moderate { border-left-color: #eab308; }
.check-item.risk-critical { border-left-color: #ef4444; }
.check-item.risk-blocked { border-left-color: #8b5cf6; }
.check-header {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin-bottom: 4px;
}
.check-risk {
font-size: 0.75rem;
}
.check-tool {
font-size: 0.7rem;
font-weight: 600;
color: var(--text-primary);
}
.check-time {
font-size: 0.6rem;
color: var(--text-secondary);
margin-left: auto;
}
.check-cmd {
display: block;
font-size: 0.65rem;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-quick-allow {
margin-top: var(--spacing-xs);
padding: 2px 8px;
font-size: 0.65rem;
font-weight: 600;
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
border-radius: var(--radius-sm);
transition: background 0.2s;
}
.btn-quick-allow:hover {
background: rgba(34, 197, 94, 0.3);
}
/* Regeln */
.rules-section {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
} }
.btn-add { .btn-add {
@ -198,18 +500,17 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
align-self: flex-start;
} }
.btn-add:hover { .btn-add:hover {
background: var(--accent-hover); background: var(--accent-hover);
} }
/* Formular */
.add-form { .add-form {
padding: var(--spacing-md); padding: var(--spacing-md);
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
margin-bottom: var(--spacing-md);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-sm); gap: var(--spacing-sm);
@ -223,6 +524,16 @@
.form-row input { .form-row input {
flex: 1; flex: 1;
font-size: 0.8rem; font-size: 0.8rem;
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid transparent;
border-radius: var(--radius-sm);
}
.form-row input:focus {
border-color: var(--accent);
outline: none;
} }
.form-row select { .form-row select {
@ -247,43 +558,19 @@
font-weight: 600; font-weight: 600;
} }
.btn-save { .btn-save { background: var(--success); color: var(--bg-primary); }
background: var(--success); .btn-save:hover { filter: brightness(1.1); }
color: var(--bg-primary); .btn-test { background: var(--bg-tertiary); color: var(--text-primary); }
} .btn-test:hover { filter: brightness(1.1); }
.btn-test { .test-result {
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--bg-tertiary); background: var(--bg-tertiary);
color: var(--text-primary); border-radius: var(--radius-sm);
}
/* Sektionen */
.section {
margin-bottom: var(--spacing-md);
}
.section h3 {
font-size: 0.8rem;
color: var(--text-secondary);
margin-bottom: var(--spacing-sm);
}
.loading-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
}
.empty-hint {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-secondary); font-weight: 500;
text-align: center;
padding: var(--spacing-md);
} }
/* Regeln */
.rules-list { .rules-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -306,12 +593,24 @@
.rule-main { .rule-main {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: var(--spacing-sm); gap: var(--spacing-sm);
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.rule-action {
font-size: 0.8rem;
flex-shrink: 0;
}
.rule-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.rule-pattern { .rule-pattern {
font-size: 0.75rem; font-size: 0.75rem;
overflow: hidden; overflow: hidden;
@ -319,27 +618,36 @@
white-space: nowrap; white-space: nowrap;
} }
.rule-tool { .rule-tags {
font-size: 0.625rem; display: flex;
padding: 1px 4px; gap: 4px;
flex-wrap: wrap;
}
.tag {
font-size: 0.55rem;
padding: 1px 5px;
border-radius: 6px;
background: var(--bg-tertiary); background: var(--bg-tertiary);
border-radius: var(--radius-sm);
color: var(--text-secondary); color: var(--text-secondary);
} }
.rule-meta { .tag.tool {
display: flex; background: rgba(96, 165, 250, 0.15);
align-items: center; color: #60a5fa;
gap: var(--spacing-xs); }
font-size: 0.625rem;
color: var(--text-secondary); .tag.count {
background: rgba(234, 179, 8, 0.15);
color: #eab308;
} }
.btn-delete { .btn-delete {
font-size: 0.75rem; font-size: 0.75rem;
padding: 2px; padding: 4px;
opacity: 0.5; opacity: 0.4;
transition: opacity 0.2s; transition: opacity 0.2s;
flex-shrink: 0;
} }
.btn-delete:hover { .btn-delete:hover {
@ -347,6 +655,22 @@
} }
/* Blockiert */ /* Blockiert */
.blocked-section {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.blocked-info {
font-size: 0.7rem;
color: var(--text-secondary);
padding: var(--spacing-sm);
background: rgba(239, 68, 68, 0.05);
border-radius: var(--radius-sm);
border-left: 3px solid var(--error);
}
.blocked-list { .blocked-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -354,14 +678,24 @@
} }
.blocked-item { .blocked-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-xs) var(--spacing-sm); padding: var(--spacing-xs) var(--spacing-sm);
background: rgba(239, 68, 68, 0.1); background: rgba(239, 68, 68, 0.08);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
}
.blocked-icon {
font-size: 0.7rem; font-size: 0.7rem;
flex-shrink: 0;
} }
.blocked-item code { .blocked-item code {
color: var(--error); color: var(--error);
font-size: 0.7rem; font-size: 0.7rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
</style> </style>

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { emit } from '@tauri-apps/api/event';
import IdePanel from './IdePanel.svelte'; import IdePanel from './IdePanel.svelte';
interface XvfbStatus { interface XvfbStatus {
@ -15,7 +16,19 @@
hint: string; hint: string;
} }
let section = $state<'ide' | 'playwright' | 'dbus' | 'xvfb'>('ide'); interface DesktopAction {
id: string;
name: string;
icon: string;
category: string;
description: string;
}
let section = $state<'actions' | 'ide' | 'playwright' | 'dbus' | 'xvfb'>('actions');
let actions = $state<DesktopAction[]>([]);
let actionResult = $state<string | null>(null);
let screenshotPreview = $state<string | null>(null);
let screenshotCapturing = $state(false);
let xvfb = $state<XvfbStatus>({ running: false, display_num: 1, pid: null, resolution: '1920x1080x24' }); let xvfb = $state<XvfbStatus>({ running: false, display_num: 1, pid: null, resolution: '1920x1080x24' });
let playwright = $state<PlaywrightInfo>({ available: false, hint: '' }); let playwright = $state<PlaywrightInfo>({ available: false, hint: '' });
@ -89,14 +102,62 @@
} }
} }
async function loadActions() {
try {
actions = await invoke<DesktopAction[]>('dbus_list_actions');
} catch (err) {
console.error('Aktionen laden:', err);
}
}
async function runAction(actionId: string, arg?: string) {
actionResult = null;
try {
const result = await invoke<any>('dbus_run_action', {
actionId,
arg: arg || null,
});
actionResult = result.success
? `✅ ${result.stdout || 'Erfolgreich'}`
: `❌ ${result.stderr || 'Fehler'}`;
} catch (err) {
actionResult = `❌ ${err}`;
}
// Ergebnis nach 3s ausblenden
setTimeout(() => { actionResult = null; }, 3000);
}
async function captureScreenshot(region: boolean = true) {
screenshotCapturing = true;
screenshotPreview = null;
try {
const b64 = await invoke<string>('capture_screenshot', { region });
screenshotPreview = `data:image/png;base64,${b64}`;
} catch (err) {
errorMsg = String(err);
} finally {
screenshotCapturing = false;
}
}
async function sendScreenshotToChat() {
if (!screenshotPreview) return;
// Ans ChatPanel senden über emit
await emit('screenshot-to-chat', { image: screenshotPreview });
actionResult = '✅ Screenshot an Chat gesendet';
setTimeout(() => { actionResult = null; }, 2000);
}
onMount(() => { onMount(() => {
loadXvfb(); loadXvfb();
loadPlaywright(); loadPlaywright();
loadActions();
}); });
</script> </script>
<div class="programs-panel"> <div class="programs-panel">
<div class="section-tabs"> <div class="section-tabs">
<button class:active={section === 'actions'} onclick={() => (section = 'actions')}>⚡ Aktionen</button>
<button class:active={section === 'ide'} onclick={() => (section = 'ide')}>🧩 VSCodium</button> <button class:active={section === 'ide'} onclick={() => (section = 'ide')}>🧩 VSCodium</button>
<button class:active={section === 'playwright'} onclick={() => (section = 'playwright')}>🎭 Playwright</button> <button class:active={section === 'playwright'} onclick={() => (section = 'playwright')}>🎭 Playwright</button>
<button class:active={section === 'dbus'} onclick={() => (section = 'dbus')}>🔌 D-Bus</button> <button class:active={section === 'dbus'} onclick={() => (section = 'dbus')}>🔌 D-Bus</button>
@ -117,7 +178,44 @@
{/if} {/if}
<div class="section-body"> <div class="section-body">
{#if section === 'ide'} {#if section === 'actions'}
<h3>⚡ Desktop-Aktionen</h3>
{#if actionResult}
<div class="action-result">{actionResult}</div>
{/if}
<div class="actions-grid">
{#each actions as action}
<button class="action-card" onclick={() => runAction(action.id)} title={action.description}>
<span class="action-icon">{action.icon}</span>
<span class="action-name">{action.name}</span>
<span class="action-cat">{action.category}</span>
</button>
{/each}
</div>
<!-- Screenshot-Analyse -->
<h3 style="margin-top: var(--spacing-md);">📸 Screenshot-Analyse</h3>
<div class="screenshot-controls">
<button class="btn-screenshot" onclick={() => captureScreenshot(true)} disabled={screenshotCapturing}>
{screenshotCapturing ? '⏳ Auswählen...' : '📐 Bereich markieren'}
</button>
<button class="btn-screenshot secondary" onclick={() => captureScreenshot(false)} disabled={screenshotCapturing}>
🖥️ Ganzer Bildschirm
</button>
</div>
{#if screenshotPreview}
<div class="screenshot-preview">
<img src={screenshotPreview} alt="Screenshot" />
<div class="screenshot-actions">
<button onclick={sendScreenshotToChat}>💬 An Claude senden</button>
<button onclick={() => screenshotPreview = null}> Verwerfen</button>
</div>
</div>
{/if}
<div class="hint">
Claude kann diese Aktionen auch direkt im Chat nutzen.
Screenshots werden als Bild an Claude gesendet — ideal zum UI-Debuggen.
</div>
{:else if section === 'ide'}
<IdePanel /> <IdePanel />
{:else if section === 'playwright'} {:else if section === 'playwright'}
<h3>🎭 Playwright (Browser-Automation)</h3> <h3>🎭 Playwright (Browser-Automation)</h3>
@ -315,6 +413,134 @@
border-radius: 2px; border-radius: 2px;
} }
/* Desktop-Aktionen Grid */
.actions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
}
.action-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: var(--spacing-md) var(--spacing-sm);
background: var(--bg-secondary);
border: 1px solid transparent;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.15s;
}
.action-card:hover {
background: var(--bg-tertiary);
border-color: var(--accent);
transform: translateY(-1px);
}
.action-icon {
font-size: 1.5rem;
}
.action-name {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-primary);
text-align: center;
}
.action-cat {
font-size: 0.6rem;
color: var(--text-secondary);
}
.action-result {
padding: var(--spacing-sm);
background: var(--bg-secondary);
border-radius: var(--radius-sm);
font-size: 0.8rem;
margin-bottom: var(--spacing-sm);
animation: fadeIn 0.2s;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
/* Screenshot-Analyse */
.screenshot-controls {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
}
.btn-screenshot {
padding: var(--spacing-sm) var(--spacing-md);
background: var(--accent);
color: white;
border: none;
border-radius: var(--radius-sm);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: filter 0.15s;
}
.btn-screenshot:hover:not(:disabled) {
filter: brightness(1.15);
}
.btn-screenshot:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-screenshot.secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
}
.screenshot-preview {
margin-bottom: var(--spacing-sm);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
}
.screenshot-preview img {
width: 100%;
display: block;
}
.screenshot-actions {
display: flex;
gap: var(--spacing-sm);
padding: var(--spacing-sm);
background: var(--bg-secondary);
}
.screenshot-actions button {
flex: 1;
padding: var(--spacing-xs) var(--spacing-sm);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
}
.screenshot-actions button:first-child {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.empty { .empty {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.8rem; font-size: 0.8rem;