feat: Guard-Rails UI, D-Bus Aktionen, Screenshot-Analyse [appimage]
All checks were successful
Build AppImage / build (push) Successful in 8m16s
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:
parent
87ba8f7bdf
commit
d4c57b777a
7 changed files with 954 additions and 131 deletions
|
|
@ -9,6 +9,9 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
|
|||
## [Unreleased] - 2026-04-21
|
||||
|
||||
### 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`)
|
||||
- **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`)
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
| 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 |
|
||||
| ✅ 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 |
|
||||
|
|
@ -91,10 +91,10 @@ Alles aus Phase 1-16 ist implementiert und funktionsfaehig:
|
|||
|
||||
| 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) |
|
||||
| ✅ 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 |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// Risiko-Klassifikation und Freigabe-Management
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
/// Risiko-Level einer Aktion
|
||||
#[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 ts = chrono::Utc::now().timestamp_millis();
|
||||
|
||||
// Wenn blockiert, sofort ablehnen
|
||||
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!({
|
||||
"allowed": false,
|
||||
"risk": "blocked",
|
||||
|
|
@ -365,9 +371,15 @@ pub async fn check_action(
|
|||
|
||||
// Permission prüfen
|
||||
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!({
|
||||
"allowed": perm.action == PermissionAction::Allow,
|
||||
"risk": format!("{:?}", risk).to_lowercase(),
|
||||
"allowed": perm_allowed,
|
||||
"risk": risk_str,
|
||||
"matched_rule": perm.id
|
||||
}));
|
||||
}
|
||||
|
|
@ -375,10 +387,24 @@ pub async fn check_action(
|
|||
// Kein Match - Frontend muss fragen
|
||||
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!({
|
||||
"allowed": risk == RiskLevel::Safe,
|
||||
"risk": format!("{:?}", risk).to_lowercase(),
|
||||
"needs_confirmation": risk != RiskLevel::Safe,
|
||||
"allowed": allowed,
|
||||
"risk": risk_str,
|
||||
"needs_confirmation": needs_confirm,
|
||||
"suggested_pattern": suggested
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -155,6 +155,9 @@ pub fn run() {
|
|||
// Programm-Steuerung (D-Bus, Xvfb, Playwright-Info)
|
||||
programs::dbus_call,
|
||||
programs::dbus_list_services,
|
||||
programs::dbus_list_actions,
|
||||
programs::dbus_run_action,
|
||||
programs::capture_screenshot,
|
||||
programs::xvfb_start,
|
||||
programs::xvfb_stop,
|
||||
programs::xvfb_status,
|
||||
|
|
|
|||
|
|
@ -95,6 +95,157 @@ pub async fn dbus_list_services(session: Option<bool>) -> Result<Vec<String>, St
|
|||
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)]
|
||||
|
|
@ -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 ============
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
|
||||
interface Permission {
|
||||
id: string;
|
||||
|
|
@ -14,29 +15,61 @@
|
|||
last_used: string | null;
|
||||
}
|
||||
|
||||
let permissions: Permission[] = [];
|
||||
let blockedPatterns: string[] = [];
|
||||
let loading = true;
|
||||
let showAddForm = false;
|
||||
interface GuardCheck {
|
||||
tool: string;
|
||||
command: string;
|
||||
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
|
||||
let newPattern = '';
|
||||
let newTool = '';
|
||||
let newAction: 'allow' | 'deny' = 'allow';
|
||||
let newType: 'session' | 'permanent' = 'permanent';
|
||||
let newPattern = $state('');
|
||||
let newTool = $state('');
|
||||
let newAction = $state<'allow' | 'deny'>('allow');
|
||||
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() {
|
||||
try {
|
||||
permissions = await invoke('get_permissions');
|
||||
blockedPatterns = await invoke('get_blocked_patterns');
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden:', err);
|
||||
console.error('Guard-Rails Ladefehler:', err);
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadData();
|
||||
onMount(async () => {
|
||||
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() {
|
||||
|
|
@ -52,9 +85,10 @@
|
|||
newPattern = '';
|
||||
newTool = '';
|
||||
showAddForm = false;
|
||||
testResult = null;
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
console.error('Fehler:', err);
|
||||
console.error('Permission-Fehler:', err);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -63,105 +97,217 @@
|
|||
await invoke('remove_permission', { id });
|
||||
await loadData();
|
||||
} 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() {
|
||||
if (!newPattern.trim()) return;
|
||||
try {
|
||||
const result = await invoke('check_action', {
|
||||
const result: any = await invoke('check_action', {
|
||||
tool: newTool || 'Bash',
|
||||
command: newPattern,
|
||||
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) {
|
||||
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>
|
||||
|
||||
<div class="guard-panel">
|
||||
<!-- Header mit Statistik-Leiste -->
|
||||
<div class="panel-header">
|
||||
<h2>🛡️ Guard-Rails</h2>
|
||||
<button class="btn-add" on:click={() => showAddForm = !showAddForm}>
|
||||
{showAddForm ? '✕' : '+ Regel'}
|
||||
<div class="stats-bar">
|
||||
{#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>
|
||||
</div>
|
||||
|
||||
<!-- Neue Regel hinzufügen -->
|
||||
{#if showAddForm}
|
||||
<div class="add-form">
|
||||
<div class="form-row">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newPattern}
|
||||
placeholder="Pattern (z.B. npm install *)"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newTool}
|
||||
placeholder="Tool (optional, z.B. Bash)"
|
||||
/>
|
||||
<select bind:value={newAction}>
|
||||
<option value="allow">Erlauben</option>
|
||||
<option value="deny">Verweigern</option>
|
||||
</select>
|
||||
<select bind:value={newType}>
|
||||
<option value="permanent">Dauerhaft</option>
|
||||
<option value="session">Session</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn-save" on:click={addPermission}>Speichern</button>
|
||||
<button class="btn-test" on:click={checkAction}>Testen</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="loading-state">Lade...</div>
|
||||
{:else}
|
||||
<!-- Aktive Regeln -->
|
||||
<div class="section">
|
||||
<h3>Aktive Regeln ({permissions.length})</h3>
|
||||
<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}
|
||||
<div class="add-form">
|
||||
<div class="form-row">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newPattern}
|
||||
placeholder="Pattern (z.B. npm install *)"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newTool}
|
||||
placeholder="Tool (optional, z.B. Bash)"
|
||||
/>
|
||||
<select bind:value={newAction}>
|
||||
<option value="allow">Erlauben</option>
|
||||
<option value="deny">Verweigern</option>
|
||||
</select>
|
||||
<select bind:value={newType}>
|
||||
<option value="permanent">Dauerhaft</option>
|
||||
<option value="session">Session</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn-save" onclick={addPermission}>Speichern</button>
|
||||
<button class="btn-test" onclick={checkAction}>Testen</button>
|
||||
</div>
|
||||
{#if testResult}
|
||||
<div class="test-result">{testResult}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#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}
|
||||
<div class="rules-list">
|
||||
{#each permissions as perm}
|
||||
<div class="rule-item" class:deny={perm.action === 'Deny'}>
|
||||
<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' ? '✅' : '🚫'}
|
||||
</span>
|
||||
<code class="rule-pattern">{perm.pattern}</code>
|
||||
{#if perm.tool}
|
||||
<span class="rule-tool">{perm.tool}</span>
|
||||
{/if}
|
||||
</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 class="rule-info">
|
||||
<code class="rule-pattern">{perm.pattern}</code>
|
||||
<div class="rule-tags">
|
||||
{#if perm.tool}
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-delete" onclick={() => removePermission(perm.id)} title="Regel löschen">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if activeTab === 'blocked'}
|
||||
<!-- Blockierte Patterns -->
|
||||
<div class="section">
|
||||
<h3>🚫 Immer blockiert ({blockedPatterns.length})</h3>
|
||||
<div class="blocked-section">
|
||||
<div class="blocked-info">
|
||||
Diese Befehle sind permanent blockiert und können nicht freigegeben werden.
|
||||
</div>
|
||||
<div class="blocked-list">
|
||||
{#each blockedPatterns as pattern}
|
||||
<div class="blocked-item">
|
||||
<span class="blocked-icon">⛔</span>
|
||||
<code>{pattern}</code>
|
||||
</div>
|
||||
{/each}
|
||||
|
|
@ -183,12 +329,168 @@
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
font-size: 1rem;
|
||||
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 {
|
||||
|
|
@ -198,18 +500,17 @@
|
|||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.btn-add:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Formular */
|
||||
.add-form {
|
||||
padding: var(--spacing-md);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--spacing-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
|
|
@ -223,6 +524,16 @@
|
|||
.form-row input {
|
||||
flex: 1;
|
||||
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 {
|
||||
|
|
@ -247,43 +558,19 @@
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: var(--success);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
.btn-save { background: var(--success); color: var(--bg-primary); }
|
||||
.btn-save:hover { filter: brightness(1.1); }
|
||||
.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);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
padding: var(--spacing-md);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Regeln */
|
||||
.rules-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -306,12 +593,24 @@
|
|||
|
||||
.rule-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-sm);
|
||||
flex: 1;
|
||||
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 {
|
||||
font-size: 0.75rem;
|
||||
overflow: hidden;
|
||||
|
|
@ -319,27 +618,36 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rule-tool {
|
||||
font-size: 0.625rem;
|
||||
padding: 1px 4px;
|
||||
.rule-tags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 0.55rem;
|
||||
padding: 1px 5px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.rule-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
font-size: 0.625rem;
|
||||
color: var(--text-secondary);
|
||||
.tag.tool {
|
||||
background: rgba(96, 165, 250, 0.15);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.tag.count {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
font-size: 0.75rem;
|
||||
padding: 2px;
|
||||
opacity: 0.5;
|
||||
padding: 4px;
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
|
|
@ -347,6 +655,22 @@
|
|||
}
|
||||
|
||||
/* 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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -354,14 +678,24 @@
|
|||
}
|
||||
|
||||
.blocked-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 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);
|
||||
}
|
||||
|
||||
.blocked-icon {
|
||||
font-size: 0.7rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.blocked-item code {
|
||||
color: var(--error);
|
||||
font-size: 0.7rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { emit } from '@tauri-apps/api/event';
|
||||
import IdePanel from './IdePanel.svelte';
|
||||
|
||||
interface XvfbStatus {
|
||||
|
|
@ -15,7 +16,19 @@
|
|||
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 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(() => {
|
||||
loadXvfb();
|
||||
loadPlaywright();
|
||||
loadActions();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="programs-panel">
|
||||
<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 === 'playwright'} onclick={() => (section = 'playwright')}>🎭 Playwright</button>
|
||||
<button class:active={section === 'dbus'} onclick={() => (section = 'dbus')}>🔌 D-Bus</button>
|
||||
|
|
@ -117,7 +178,44 @@
|
|||
{/if}
|
||||
|
||||
<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 />
|
||||
{:else if section === 'playwright'}
|
||||
<h3>🎭 Playwright (Browser-Automation)</h3>
|
||||
|
|
@ -315,6 +413,134 @@
|
|||
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 {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
|
|
|
|||
Loading…
Reference in a new issue