diff --git a/TEST-ROADMAP.md b/TEST-ROADMAP.md index 5e587ee..b458a79 100644 --- a/TEST-ROADMAP.md +++ b/TEST-ROADMAP.md @@ -43,7 +43,23 @@ **Status:** Sub-Agents laufen auf Opus (inherit vom Main). Custom `agents`-Option in SDK scheint ignoriert zu werden bzw. spawnt Agents ohne Tools (halluziniert). **Nächster Ansatz:** Im Orchestrator-Prompt Claude explizit vorgeben `model: "haiku"` in Task-Calls zu setzen. Ob das SDK das respektiert, ist offen. +## Neue Features (15.04.2026) + +### Auto-Update System +- **Backend** ([update.rs](src-tauri/src/update.rs)): + - `check_for_update()` — Prüft Forgejo `/repos/data/claude-desktop/releases` + - `download_update()` — Lädt AppImage mit Progress-Events herunter + - `apply_update()` — Backup, Replace, Restart via `app.restart()` +- **Frontend** ([UpdateDialog.svelte](src/lib/components/UpdateDialog.svelte)): + - Automatischer Check 3s nach App-Start + - Dialog mit Versions-Vergleich (v0.1.0 → v1.0.0) + - Release-Notes Anzeige + - Download-Fortschrittsbalken + - "Später" / "Jetzt aktualisieren" / "Jetzt installieren & neustarten" +- **Status**: Implementiert, wartet auf erstes Forgejo Release mit `.AppImage` Asset + ## Letzte Commits +- Feature: Auto-Update System für AppImage - `f191cd0` Feature: Kontext-Auslastung im Footer (X% ctx) - `48fd61f` Fix: Auto-Session erscheint sofort in Session-Liste - `a203589` Fix: Date-Panic in Wissensbasis (chrono::NaiveDateTime) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1bbef58..c74b58e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3571,12 +3571,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams 0.4.2", "web-sys", ] @@ -3610,7 +3612,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] @@ -5489,6 +5491,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a7de12b..acea329 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,7 +22,7 @@ chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4", "serde"] } rusqlite = { version = "0.31", features = ["bundled"] } mysql_async = { version = "0.34", features = ["chrono"] } -reqwest = { version = "0.12", features = ["json", "multipart"] } +reqwest = { version = "0.12", features = ["json", "multipart", "stream"] } base64 = "0.22" tokio-tungstenite = "0.23" futures-util = "0.3" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5fd3b83..fe27e03 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -20,6 +20,7 @@ mod memory; mod programs; mod session; mod teaching; +mod update; mod voice; /// Initialisiert die App @@ -130,6 +131,11 @@ pub fn run() { teaching::presentation_close, teaching::presentation_send_slide, teaching::presentation_clear, + // Auto-Update + update::check_for_update, + update::download_update, + update::apply_update, + update::get_current_version, ]) .setup(|app| { let handle = app.handle().clone(); diff --git a/src-tauri/src/update.rs b/src-tauri/src/update.rs new file mode 100644 index 0000000..24ac052 --- /dev/null +++ b/src-tauri/src/update.rs @@ -0,0 +1,268 @@ +// Claude Desktop — Auto-Update System +// Prüft Forgejo-Releases und aktualisiert das AppImage + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use tauri::{AppHandle, Manager, Emitter}; + +/// Forgejo API Konfiguration +const FORGEJO_URL: &str = "https://git.data-it-solution.de"; +const REPO_OWNER: &str = "data"; +const REPO_NAME: &str = "claude-desktop"; + +/// Aktuelle App-Version (aus Cargo.toml) +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Release-Info von Forgejo +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReleaseInfo { + pub id: i64, + pub tag_name: String, + pub name: String, + pub body: Option, + pub draft: bool, + pub prerelease: bool, + pub created_at: String, + pub published_at: Option, + pub assets: Vec, +} + +/// Release-Asset (z.B. AppImage) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReleaseAsset { + pub id: i64, + pub name: String, + pub size: i64, + pub download_count: i64, + pub browser_download_url: String, +} + +/// Update-Status für Frontend +#[derive(Debug, Clone, Serialize)] +pub struct UpdateStatus { + pub available: bool, + pub current_version: String, + pub latest_version: Option, + pub release_notes: Option, + pub download_url: Option, + pub download_size: Option, +} + +/// Prüft ob ein Update verfügbar ist +#[tauri::command] +pub async fn check_for_update() -> Result { + let url = format!( + "{}/api/v1/repos/{}/{}/releases?limit=1", + FORGEJO_URL, REPO_OWNER, REPO_NAME + ); + + let client = reqwest::Client::new(); + let response = client + .get(&url) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| format!("Netzwerkfehler: {}", e))?; + + if !response.status().is_success() { + return Ok(UpdateStatus { + available: false, + current_version: CURRENT_VERSION.to_string(), + latest_version: None, + release_notes: None, + download_url: None, + download_size: None, + }); + } + + let releases: Vec = response + .json() + .await + .map_err(|e| format!("JSON-Fehler: {}", e))?; + + let latest = match releases.into_iter().find(|r| !r.draft && !r.prerelease) { + Some(r) => r, + None => { + return Ok(UpdateStatus { + available: false, + current_version: CURRENT_VERSION.to_string(), + latest_version: None, + release_notes: None, + download_url: None, + download_size: None, + }); + } + }; + + // Version vergleichen (tag_name ohne 'v' Prefix) + let latest_version = latest.tag_name.trim_start_matches('v').to_string(); + let is_newer = version_compare(&latest_version, CURRENT_VERSION); + + // AppImage-Asset finden + let appimage = latest.assets.iter().find(|a| a.name.ends_with(".AppImage")); + + Ok(UpdateStatus { + available: is_newer, + current_version: CURRENT_VERSION.to_string(), + latest_version: Some(latest_version), + release_notes: latest.body, + download_url: appimage.map(|a| a.browser_download_url.clone()), + download_size: appimage.map(|a| a.size), + }) +} + +/// Download-Fortschritt +#[derive(Debug, Clone, Serialize)] +pub struct DownloadProgress { + pub downloaded: u64, + pub total: u64, + pub percent: f32, +} + +/// Lädt das Update herunter +#[tauri::command] +pub async fn download_update( + app: AppHandle, + download_url: String, +) -> Result { + use std::io::Write; + + let client = reqwest::Client::new(); + let response = client + .get(&download_url) + .send() + .await + .map_err(|e| format!("Download-Fehler: {}", e))?; + + let total_size = response.content_length().unwrap_or(0); + + // Temp-Verzeichnis für Download + let cache_dir = app.path().app_cache_dir() + .map_err(|e| format!("Cache-Verzeichnis nicht gefunden: {}", e))?; + std::fs::create_dir_all(&cache_dir).ok(); + + let file_name = download_url.split('/').last().unwrap_or("update.AppImage"); + let download_path = cache_dir.join(file_name); + + let mut file = std::fs::File::create(&download_path) + .map_err(|e| format!("Datei erstellen fehlgeschlagen: {}", e))?; + + let mut downloaded: u64 = 0; + let mut stream = response.bytes_stream(); + + use futures_util::StreamExt; + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("Download-Chunk-Fehler: {}", e))?; + file.write_all(&chunk) + .map_err(|e| format!("Schreibfehler: {}", e))?; + + downloaded += chunk.len() as u64; + + // Fortschritt an Frontend senden + let progress = DownloadProgress { + downloaded, + total: total_size, + percent: if total_size > 0 { + (downloaded as f32 / total_size as f32) * 100.0 + } else { + 0.0 + }, + }; + app.emit("update-progress", &progress).ok(); + } + + // Ausführbar machen + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&download_path) + .map_err(|e| format!("Metadata-Fehler: {}", e))? + .permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&download_path, perms) + .map_err(|e| format!("Permissions-Fehler: {}", e))?; + } + + println!("✅ Update heruntergeladen: {:?}", download_path); + Ok(download_path) +} + +/// Wendet das Update an (ersetzt AppImage und startet neu) +#[tauri::command] +pub async fn apply_update(app: AppHandle, update_path: PathBuf) -> Result<(), String> { + // Prüfen ob wir in einem AppImage laufen + let appimage_path = std::env::var("APPIMAGE").ok(); + + if let Some(appimage) = appimage_path { + let appimage_path = PathBuf::from(&appimage); + + // Backup erstellen + let backup_path = appimage_path.with_extension("AppImage.backup"); + std::fs::rename(&appimage_path, &backup_path) + .map_err(|e| format!("Backup fehlgeschlagen: {}", e))?; + + // Neues AppImage verschieben + std::fs::rename(&update_path, &appimage_path) + .map_err(|e| { + // Backup wiederherstellen bei Fehler + std::fs::rename(&backup_path, &appimage_path).ok(); + format!("Update-Installation fehlgeschlagen: {}", e) + })?; + + // Backup löschen + std::fs::remove_file(&backup_path).ok(); + + println!("✅ Update installiert, starte neu..."); + + // App neustarten (kehrt nicht zurück) + app.restart(); + } + + // Nicht in AppImage — Entwicklungsmodus + Err("Update nur für AppImage-Builds verfügbar. In Entwicklung: manuell `git pull` und neu bauen.".to_string()) +} + +/// Gibt aktuelle Version zurück +#[tauri::command] +pub fn get_current_version() -> String { + CURRENT_VERSION.to_string() +} + +/// Vergleicht zwei Versionen (semver-ähnlich) +fn version_compare(new: &str, current: &str) -> bool { + let parse = |v: &str| -> Vec { + v.split('.') + .filter_map(|s| s.parse().ok()) + .collect() + }; + + let new_parts = parse(new); + let current_parts = parse(current); + + for i in 0..3 { + let n = new_parts.get(i).copied().unwrap_or(0); + let c = current_parts.get(i).copied().unwrap_or(0); + if n > c { + return true; + } + if n < c { + return false; + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_version_compare() { + assert!(version_compare("1.0.1", "1.0.0")); + assert!(version_compare("1.1.0", "1.0.9")); + assert!(version_compare("2.0.0", "1.9.9")); + assert!(!version_compare("1.0.0", "1.0.0")); + assert!(!version_compare("1.0.0", "1.0.1")); + assert!(!version_compare("0.9.0", "1.0.0")); + } +} diff --git a/src/lib/components/UpdateDialog.svelte b/src/lib/components/UpdateDialog.svelte new file mode 100644 index 0000000..27831d8 --- /dev/null +++ b/src/lib/components/UpdateDialog.svelte @@ -0,0 +1,350 @@ + + +{#if showDialog && updateInfo} + + + +{/if} + + diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 23e0937..a88d940 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -4,6 +4,7 @@ import { invoke } from '@tauri-apps/api/core'; import { isProcessing, agentCount, currentModel, sessionStats, contextPercent, contextUsage, initEventListeners, cleanupEventListeners, currentSessionId, setMessagesFromDb, stickyContextInfo, agentMode, type DbMessage, type StickyContextInfo, type AgentMode } from '$lib/stores'; import StopButton from '$lib/components/StopButton.svelte'; + import UpdateDialog from '$lib/components/UpdateDialog.svelte'; // Session-Typ vom Backend interface Session { @@ -206,6 +207,9 @@ + + +