Security-Fixes + UI-Verbesserungen: Stop-Button, Textfeld, Agent-Filter
All checks were successful
Build AppImage / build (push) Has been skipped
All checks were successful
Build AppImage / build (push) Has been skipped
Backend: - Credentials aus Code entfernt → ENV-Variablen mit Fallback - File-Traversal in Update-Download verhindert (Path-Sanitization) - CLI-Injection bei D-Bus mit Whitelist-Validierung abgesichert Frontend: - Stop-Button dezenter (kleinere Schrift, gedämpftes Rot, kein Pulsieren) - Stop löscht keine Session/Messages mehr — nur Agents stoppen - Textfeld nicht mehr blockiert während Claude arbeitet (Einwände möglich) - Agent-Filter "Nur aktive" wird in localStorage persistent gespeichert Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
427fa858a9
commit
3993387977
8 changed files with 192 additions and 44 deletions
139
README.md
139
README.md
|
|
@ -2,6 +2,145 @@
|
||||||
|
|
||||||
Eigenständige Desktop-Anwendung die Claude Code als Backend nutzt, mit nativem UI, Live-Übersicht und kontrolliertem OS-Zugriff.
|
Eigenständige Desktop-Anwendung die Claude Code als Backend nutzt, mit nativem UI, Live-Übersicht und kontrolliertem OS-Zugriff.
|
||||||
|
|
||||||
|
## Status (Stand 2026-04-20)
|
||||||
|
|
||||||
|
**Variante A (Native Desktop-App) ist umgesetzt** — Tauri 2.0 + SvelteKit 5, Phase 1-13 fertig (siehe [ROADMAP.md](ROADMAP.md)). Variante B (autonome VM) bleibt Vision.
|
||||||
|
|
||||||
|
Was läuft:
|
||||||
|
- 4-Panel-Layout, 24 UI-Komponenten (Chat, Activity, Memory, Audit, Knowledge, Voice, Hooks, IDE, Programs, Performance, Settings, …)
|
||||||
|
- 16 Rust-Backend-Module (`claude.rs`, `db.rs`, `guard.rs`, `memory.rs`, `voice.rs`, `hooks.rs`, `ide.rs`, …)
|
||||||
|
- Sprach-Interface mit Push-to-Talk (`VoicePanel`), Whisper STT
|
||||||
|
- Modell-Auswahl Haiku/Sonnet/Opus, Token-/Kosten-Anzeige
|
||||||
|
- Subagent-Hierarchie, Multi-Agent-Modi, Hook-System
|
||||||
|
- Session-Persistenz (SQLite), Audit-Log
|
||||||
|
- VS-Code-Extension `claude-desktop-bridge` (steuert VSCodium aus der App heraus, WebSocket-Port 7890)
|
||||||
|
- CI/CD-Pipeline (Forgejo Actions) → AppImage in Package-Registry
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### AppImage (Debian/Ubuntu/Fedora/Arch — **nicht NixOS**)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/Applications
|
||||||
|
curl -sSL -o ~/Applications/Claude-Desktop.AppImage \
|
||||||
|
-u "token:<dein-forgejo-token>" \
|
||||||
|
'https://git.data-it-solution.de/api/packages/data/generic/claude-desktop/latest/Claude-Desktop_0.1.0_amd64.AppImage'
|
||||||
|
chmod +x ~/Applications/Claude-Desktop.AppImage
|
||||||
|
~/Applications/Claude-Desktop.AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
Auf NixOS hat das AppImage einen WebKit2GTK ↔ Mesa ABI-Konflikt → siehe nächster Abschnitt.
|
||||||
|
|
||||||
|
### NixOS — nativer Build (Pflicht)
|
||||||
|
|
||||||
|
Das AppImage läuft auf NixOS nicht (KB-Eintrag #381 zur Diagnose). Stattdessen lokal bauen mit der mitgelieferten `shell.nix`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "/mnt/17 - Entwicklungen/20 - Projekte/ClaudeDesktop"
|
||||||
|
|
||||||
|
# Cargo-Target auf lokales tmpfs (SMB-Mount macht Cargo-Build-Errors)
|
||||||
|
CARGO_TARGET_DIR=/tmp/claude-target \
|
||||||
|
nix-shell shell.nix --run 'npm ci && npm run tauri build -- --bundles appimage'
|
||||||
|
|
||||||
|
# Tauri-Bundling kann an pkg-config-Bug scheitern — Binary reicht aber:
|
||||||
|
nix-shell shell.nix --run /tmp/claude-target/release/claude-desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
Permanenter Wrapper:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > ~/.local/bin/claude-desktop <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
cd "/mnt/17 - Entwicklungen/20 - Projekte/ClaudeDesktop"
|
||||||
|
exec nix-shell shell.nix --run /tmp/claude-target/release/claude-desktop
|
||||||
|
EOF
|
||||||
|
chmod +x ~/.local/bin/claude-desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "/mnt/17 - Entwicklungen/20 - Projekte/ClaudeDesktop"
|
||||||
|
nix-shell shell.nix --run 'npm ci && npm run tauri:dev' # mit Hot-Reload
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD-Pipeline
|
||||||
|
|
||||||
|
Workflow: `.forgejo/workflows/build-appimage.yml`
|
||||||
|
|
||||||
|
| Trigger | Was passiert |
|
||||||
|
|---|---|
|
||||||
|
| Push auf `main` mit `[appimage]` in Commit-Message | AppImage bauen + in Package-Registry hochladen |
|
||||||
|
| Push eines Tags `v*` | zusätzlich als Release-Asset anhängen |
|
||||||
|
|
||||||
|
**Runner:** `16-Forgejo-Runner-AppImage` (Debian Bookworm, glibc, mit linuxdeploy + appimagetool + Tauri-Build-Stack vorinstalliert). Der Standard-Alpine-Runner kann Tauri-AppImages nicht bauen (musl-Inkompatibilität von linuxdeploy). Setup des Runners: KB-Eintrag #371.
|
||||||
|
|
||||||
|
**Workflow-Eigenheiten** (gut zu wissen wenn was ändern):
|
||||||
|
- AppImage-Filename mit Leerzeichen (`Claude Desktop_…`) wird vor Upload zu `Claude-Desktop_…` umbenannt (curl-URL-Bug)
|
||||||
|
- Vor jedem Upload werden alte Versionen gelöscht (Forgejo Package-Registry weist PUT auf existierenden Pfad mit HTTP 409 ab)
|
||||||
|
- Custom-AppRun mit NixOS-Detection wird nach `tauri build` eingesetzt + mit `appimagetool` re-bundled. Der Hook `apprun-hooks/linuxdeploy-plugin-gtk.sh` darf dabei nicht überschrieben werden (KB #384), sonst finden WebKit-Subprozesse ihre Helpers nicht
|
||||||
|
- Ntfy-Notifications (Build-Start/Success/Failure) — Topic `vk-builds`
|
||||||
|
|
||||||
|
Ntfy-Setup für andere Projekte: KB #190/#191/#220.
|
||||||
|
|
||||||
|
## VS-Code-Extension `claude-desktop-bridge`
|
||||||
|
|
||||||
|
Eigenes Subprojekt unter [vscode-extension/](vscode-extension/). Ermöglicht Claude Desktop, VSCodium fernzusteuern (Datei öffnen, Cursor-Position setzen, Terminal-Befehle absetzen) über einen WebSocket-Server (Port 7890 default).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd vscode-extension
|
||||||
|
npm ci && npm run compile
|
||||||
|
# Verpacktes vsix: claude-desktop-bridge-0.1.0.vsix
|
||||||
|
codium --install-extension claude-desktop-bridge-0.1.0.vsix
|
||||||
|
```
|
||||||
|
|
||||||
|
Befehle in VSCodium: `Claude Desktop: Verbindung starten/beenden`. Die App meldet sich beim Start automatisch.
|
||||||
|
|
||||||
|
## Projektstruktur (Stand)
|
||||||
|
|
||||||
|
```
|
||||||
|
ClaudeDesktop/
|
||||||
|
├── src-tauri/src/ # 16 Rust-Module
|
||||||
|
│ ├── main.rs # Entry (setzt WEBKIT_DISABLE_*-Defaults für Linux)
|
||||||
|
│ ├── lib.rs # Tauri-App-Setup
|
||||||
|
│ ├── claude.rs # Claude Agent SDK Integration
|
||||||
|
│ ├── db.rs # MySQL (claude-DB) + SQLite-Persistierung
|
||||||
|
│ ├── guard.rs # Guard-Rails (Critical/Moderate/Safe)
|
||||||
|
│ ├── hooks.rs # Hook-System
|
||||||
|
│ ├── memory.rs # Memory/Knowledge-Graph
|
||||||
|
│ ├── voice.rs # Whisper STT + Voice-Activity-Detection
|
||||||
|
│ ├── ide.rs # Bridge zur VSCodium-Extension
|
||||||
|
│ ├── audit.rs, knowledge.rs, programs.rs, session.rs,
|
||||||
|
│ │ teaching.rs, update.rs, context.rs
|
||||||
|
│ └── ...
|
||||||
|
├── src/ # SvelteKit Frontend
|
||||||
|
│ ├── routes/+layout.svelte
|
||||||
|
│ ├── routes/+page.svelte
|
||||||
|
│ ├── routes/presentation/+page.svelte
|
||||||
|
│ └── lib/components/ # 24 UI-Panels
|
||||||
|
├── vscode-extension/ # VSCodium-Bridge
|
||||||
|
├── .forgejo/workflows/ # CI/CD
|
||||||
|
├── shell.nix # NixOS Dev-Shell
|
||||||
|
├── ROADMAP.md # Phase-Status
|
||||||
|
├── TEST-ROADMAP.md # Test-Plan
|
||||||
|
├── tools.yaml # MCP-Tool-Inventar
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wissensbasis-Referenzen (für künftige Sessions)
|
||||||
|
|
||||||
|
| KB-ID | Thema |
|
||||||
|
|---|---|
|
||||||
|
| #311 | Diese Pipeline (Workflow-Tricks) |
|
||||||
|
| #371 | Debian Forgejo-Runner Setup |
|
||||||
|
| #372 | libssl-dev für openssl-sys |
|
||||||
|
| #381 | NixOS WebKit2GTK EGL-Crash → native build |
|
||||||
|
| #382 | Cargo auf SMB → CARGO_TARGET_DIR umleiten |
|
||||||
|
| #384 | Custom-AppRun + linuxdeploy-Hook |
|
||||||
|
| #248 | Tauri 2.0 + SvelteKit auf NixOS shell.nix |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Motivation
|
## Motivation
|
||||||
|
|
||||||
Claude Code in VSCodium funktioniert, hat aber Grenzen:
|
Claude Code in VSCodium funktioniert, hat aber Grenzen:
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,13 @@ use mysql_async::{Pool, prelude::*};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
|
|
||||||
/// Verbindungskonfiguration
|
/// Verbindungskonfiguration — aus ENV-Variablen, Fallback auf Defaults
|
||||||
const MYSQL_HOST: &str = "192.168.155.11";
|
|
||||||
const MYSQL_PORT: u16 = 3306;
|
const MYSQL_PORT: u16 = 3306;
|
||||||
const MYSQL_USER: &str = "claude";
|
|
||||||
const MYSQL_PASS: &str = "8715";
|
fn mysql_host() -> String { std::env::var("CLAUDE_MYSQL_HOST").unwrap_or_else(|_| "192.168.155.11".to_string()) }
|
||||||
const MYSQL_DB: &str = "claude";
|
fn mysql_user() -> String { std::env::var("CLAUDE_MYSQL_USER").unwrap_or_else(|_| "claude".to_string()) }
|
||||||
|
fn mysql_pass() -> String { std::env::var("CLAUDE_MYSQL_PASS").unwrap_or_else(|_| "8715".to_string()) }
|
||||||
|
fn mysql_db() -> String { std::env::var("CLAUDE_MYSQL_DB").unwrap_or_else(|_| "claude".to_string()) }
|
||||||
|
|
||||||
/// Wissenseintrag aus der knowledge-Tabelle
|
/// Wissenseintrag aus der knowledge-Tabelle
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -59,7 +60,7 @@ pub struct CategoryInfo {
|
||||||
fn create_pool() -> Pool {
|
fn create_pool() -> Pool {
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"mysql://{}:{}@{}:{}/{}",
|
"mysql://{}:{}@{}:{}/{}",
|
||||||
MYSQL_USER, MYSQL_PASS, MYSQL_HOST, MYSQL_PORT, MYSQL_DB
|
mysql_user(), mysql_pass(), mysql_host(), MYSQL_PORT, mysql_db()
|
||||||
);
|
);
|
||||||
Pool::new(url.as_str())
|
Pool::new(url.as_str())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,17 @@ pub async fn dbus_call(
|
||||||
) -> Result<DbusCallResult, String> {
|
) -> Result<DbusCallResult, String> {
|
||||||
let bus = if session.unwrap_or(true) { "--session" } else { "--system" };
|
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");
|
let mut cmd = Command::new("dbus-send");
|
||||||
cmd.arg("--print-reply")
|
cmd.arg("--print-reply")
|
||||||
.arg(bus)
|
.arg(bus)
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,11 @@ pub async fn download_update(
|
||||||
std::fs::create_dir_all(&cache_dir).ok();
|
std::fs::create_dir_all(&cache_dir).ok();
|
||||||
|
|
||||||
let file_name = download_url.split('/').last().unwrap_or("update.AppImage");
|
let file_name = download_url.split('/').last().unwrap_or("update.AppImage");
|
||||||
|
// Sanitize: Nur Basename, keine Pfad-Traversal (z.B. "../böse.sh")
|
||||||
|
let file_name = std::path::Path::new(file_name)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|f| f.to_str())
|
||||||
|
.unwrap_or("update.AppImage");
|
||||||
let download_path = cache_dir.join(file_name);
|
let download_path = cache_dir.join(file_name);
|
||||||
|
|
||||||
let mut file = std::fs::File::create(&download_path)
|
let mut file = std::fs::File::create(&download_path)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,11 @@
|
||||||
import type { Agent } from '$lib/stores/app';
|
import type { Agent } from '$lib/stores/app';
|
||||||
import { derived } from 'svelte/store';
|
import { derived } from 'svelte/store';
|
||||||
|
|
||||||
let onlyActive = $state(false);
|
// Filter-State persistent in localStorage speichern
|
||||||
|
let onlyActive = $state(localStorage.getItem('agentFilter_onlyActive') === 'true');
|
||||||
|
$effect(() => {
|
||||||
|
localStorage.setItem('agentFilter_onlyActive', String(onlyActive));
|
||||||
|
});
|
||||||
|
|
||||||
// Gefilterter Tree: wenn onlyActive, dann nur Agents mit status==='active'
|
// Gefilterter Tree: wenn onlyActive, dann nur Agents mit status==='active'
|
||||||
const filteredTree = derived([agentTree], ([$tree]) => {
|
const filteredTree = derived([agentTree], ([$tree]) => {
|
||||||
|
|
|
||||||
|
|
@ -393,7 +393,7 @@
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const text = $currentInput.trim();
|
const text = $currentInput.trim();
|
||||||
if (!text || $isProcessing) return;
|
if (!text) return;
|
||||||
|
|
||||||
// Auto-Session erstellen falls keine aktiv
|
// Auto-Session erstellen falls keine aktiv
|
||||||
let sessionId = get(currentSessionId);
|
let sessionId = get(currentSessionId);
|
||||||
|
|
@ -747,7 +747,7 @@
|
||||||
bind:value={$currentInput}
|
bind:value={$currentInput}
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
placeholder="Nachricht eingeben... (Ctrl+K = Focus, Ctrl+Enter = Senden)"
|
placeholder="Nachricht eingeben... (Ctrl+K = Focus, Ctrl+Enter = Senden)"
|
||||||
disabled={$isProcessing || isRecording}
|
disabled={isRecording}
|
||||||
rows="3"
|
rows="3"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="input-buttons">
|
<div class="input-buttons">
|
||||||
|
|
@ -768,7 +768,7 @@
|
||||||
<button
|
<button
|
||||||
class="send-button"
|
class="send-button"
|
||||||
onclick={sendMessage}
|
onclick={sendMessage}
|
||||||
disabled={!$currentInput.trim() || $isProcessing || isRecording}
|
disabled={!$currentInput.trim() || isRecording}
|
||||||
>
|
>
|
||||||
{#if $isProcessing}
|
{#if $isProcessing}
|
||||||
⏳
|
⏳
|
||||||
|
|
|
||||||
|
|
@ -30,30 +30,27 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: var(--spacing-md);
|
gap: var(--spacing-xs);
|
||||||
padding: var(--spacing-md) var(--spacing-lg);
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
background: linear-gradient(135deg, var(--error), #c0392b);
|
background: #c53030;
|
||||||
color: white;
|
color: rgba(255, 255, 255, 0.85);
|
||||||
font-size: 1rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
letter-spacing: 0.02em;
|
||||||
letter-spacing: 0.05em;
|
border-radius: var(--radius-sm);
|
||||||
border-radius: var(--radius-md);
|
border: 1px solid rgba(197, 48, 48, 0.6);
|
||||||
border: 2px solid var(--error);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.15s ease;
|
||||||
box-shadow: 0 4px 15px rgba(218, 68, 83, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stop-button:not(.disabled):hover {
|
.stop-button:not(.disabled):hover {
|
||||||
background: linear-gradient(135deg, #e74c3c, var(--error));
|
background: #b52828;
|
||||||
transform: translateY(-1px);
|
color: white;
|
||||||
box-shadow: 0 6px 20px rgba(218, 68, 83, 0.4);
|
border-color: rgba(197, 48, 48, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stop-button:not(.disabled):active {
|
.stop-button:not(.disabled):active {
|
||||||
transform: translateY(0);
|
background: #a02020;
|
||||||
box-shadow: 0 2px 10px rgba(218, 68, 83, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stop-button.disabled {
|
.stop-button.disabled {
|
||||||
|
|
@ -61,26 +58,15 @@
|
||||||
border-color: var(--bg-tertiary);
|
border-color: var(--bg-tertiary);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
box-shadow: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stop-icon {
|
.stop-icon {
|
||||||
font-size: 1.25rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stop-hint {
|
.stop-hint {
|
||||||
font-size: 0.75rem;
|
font-size: 0.65rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
opacity: 0.8;
|
opacity: 0.65;
|
||||||
}
|
|
||||||
|
|
||||||
/* Animierter Rand wenn aktiv */
|
|
||||||
.stop-button:not(.disabled) {
|
|
||||||
animation: border-pulse 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes border-pulse {
|
|
||||||
0%, 100% { border-color: var(--error); }
|
|
||||||
50% { border-color: #e88; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import {
|
||||||
updateAgentStatus,
|
updateAgentStatus,
|
||||||
addToolCall,
|
addToolCall,
|
||||||
completeToolCall,
|
completeToolCall,
|
||||||
clearAll,
|
|
||||||
currentModel,
|
currentModel,
|
||||||
sessionStats,
|
sessionStats,
|
||||||
contextUsage,
|
contextUsage,
|
||||||
|
|
@ -333,12 +332,15 @@ export async function initEventListeners(): Promise<void> {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// STOPP-Signal
|
// STOPP-Signal — nur Agents stoppen, Messages/Session bleiben erhalten
|
||||||
listeners.push(
|
listeners.push(
|
||||||
await listen('agents-stopped', () => {
|
await listen('agents-stopped', () => {
|
||||||
console.log('🛑 STOPP-Signal empfangen');
|
console.log('🛑 STOPP-Signal empfangen');
|
||||||
streamingMessageId = null;
|
streamingMessageId = null;
|
||||||
clearAll();
|
// Alle Agents auf "stopped" setzen, aber Messages NICHT löschen
|
||||||
|
agents.update((ags) => ags.map((a) => ({ ...a, status: 'stopped' as const })));
|
||||||
|
toolCalls.set([]);
|
||||||
|
isProcessing.set(false);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue