// Claude Desktop — Auto-Update System // Liest update.json aus der Forgejo Package Registry, lädt AppImage, prüft SHA256, startet neu. // Version-Schema YYYYMMDD-HHMM wird von der CI als APP_VERSION zur Build-Zeit injiziert. use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::path::PathBuf; use tauri::{AppHandle, Emitter, Manager}; /// Endpoint der Manifest-Datei const UPDATE_JSON_URL: &str = "https://git.data-it-solution.de/api/packages/data/generic/claude-desktop/latest/update.json"; /// Basis-URL für versionierte Asset-Downloads (Datei wird angehängt) const PACKAGE_BASE_URL: &str = "https://git.data-it-solution.de/api/packages/data/generic/claude-desktop/latest/"; /// Version der laufenden App. Wird von der CI als APP_VERSION zur Build-Zeit gesetzt. /// Lokaler Build ohne Env-Var → "dev" → Update-Check wird übersprungen. const CURRENT_VERSION: &str = match option_env!("APP_VERSION") { Some(v) => v, None => "dev", }; /// Read-Only-Token für Forgejo-Package-Registry (Basic-Auth user: "data") /// Wird ebenfalls zur Build-Zeit gesetzt. Ohne Token → Updates nicht abrufbar. const UPDATE_TOKEN: Option<&str> = option_env!("UPDATE_TOKEN"); /// Basic-Auth-User für Forgejo const UPDATE_USER: &str = "data"; /// Manifest aus latest/update.json. /// `filename`/`sha256` → AppImage (Standard-Installation). /// `binary_filename`/`binary_sha256` → pures Binary (Nix-Wrapper-Installation). /// Ältere Manifest-Versionen ohne Binary-Felder werden toleriert (Option). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateManifest { pub version: String, pub filename: String, pub sha256: String, #[serde(default)] pub binary_filename: Option, #[serde(default)] pub binary_sha256: Option, pub notes: Option, pub released_at: Option, } /// Prüft ob die App via Nix-Wrapper läuft (Env-Var vom Launcher-Script in nix/default.nix). fn is_nix_wrapper() -> bool { std::env::var("CLAUDE_DESKTOP_NIX_WRAPPER").ok().as_deref() == Some("1") } /// Update-Status für das 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, pub sha256: Option, } /// Download-Fortschritt #[derive(Debug, Clone, Serialize)] pub struct DownloadProgress { pub downloaded: u64, pub total: u64, pub percent: f32, } /// Baut einen reqwest::RequestBuilder mit Basic-Auth (wenn Token vorhanden) fn authed_get(client: &reqwest::Client, url: &str) -> reqwest::RequestBuilder { let req = client.get(url).header("Accept", "application/json"); match UPDATE_TOKEN { Some(token) => { let creds = B64.encode(format!("{}:{}", UPDATE_USER, token)); req.header("Authorization", format!("Basic {}", creds)) } None => req, } } /// Prüft ob ein Update verfügbar ist. /// Lokale Dev-Builds ohne APP_VERSION-Env geben sofort available=false zurück. #[tauri::command] pub async fn check_for_update() -> Result { // Dev-Build → kein Update-Check if CURRENT_VERSION == "dev" { return Ok(UpdateStatus { available: false, current_version: CURRENT_VERSION.to_string(), latest_version: None, release_notes: Some("Entwicklungs-Build — Updates deaktiviert.".to_string()), download_url: None, download_size: None, sha256: None, }); } let client = reqwest::Client::new(); let response = authed_get(&client, UPDATE_JSON_URL) .send() .await .map_err(|e| format!("Netzwerkfehler beim Update-Check: {}", e))?; if !response.status().is_success() { return Err(format!( "Manifest-Abruf fehlgeschlagen ({}). Token konfiguriert: {}", response.status(), UPDATE_TOKEN.is_some() )); } let manifest: UpdateManifest = response .json() .await .map_err(|e| format!("Manifest-JSON-Fehler: {}", e))?; let available = is_newer(&manifest.version, CURRENT_VERSION); // Im Nix-Wrapper-Modus das pure Binary ziehen (AppImage laeuft auf NixOS nicht). // Fehlt das Binary-Feld im Manifest (alte CI-Version) → Update als nicht-anwendbar markieren. let (chosen_filename, chosen_sha256) = if is_nix_wrapper() { match (&manifest.binary_filename, &manifest.binary_sha256) { (Some(f), Some(h)) => (f.clone(), h.clone()), _ => { return Ok(UpdateStatus { available: false, current_version: CURRENT_VERSION.to_string(), latest_version: Some(manifest.version), release_notes: Some( "Update vorhanden, aber das Manifest enthält kein Binary für den Nix-Wrapper-Modus. Bitte manuell neu bauen." .to_string(), ), download_url: None, download_size: None, sha256: None, }); } } } else { (manifest.filename, manifest.sha256) }; let download_url = format!("{}{}", PACKAGE_BASE_URL, chosen_filename); Ok(UpdateStatus { available, current_version: CURRENT_VERSION.to_string(), latest_version: Some(manifest.version), release_notes: manifest.notes, download_url: Some(download_url), // Größe kennen wir erst beim Download (Content-Length) — hier None download_size: None, sha256: Some(chosen_sha256), }) } /// Lädt das Update herunter, verifiziert SHA256, gibt den Pfad zurück. #[tauri::command] pub async fn download_update( app: AppHandle, download_url: String, expected_sha256: Option, ) -> Result { use std::io::Write; let client = reqwest::Client::new(); let response = match UPDATE_TOKEN { Some(token) => { let creds = B64.encode(format!("{}:{}", UPDATE_USER, token)); client .get(&download_url) .header("Authorization", format!("Basic {}", creds)) } None => client.get(&download_url), } .send() .await .map_err(|e| format!("Download-Fehler: {}", e))?; if !response.status().is_success() { return Err(format!( "Download-HTTP-Fehler: {}", response.status() )); } let total_size = response.content_length().unwrap_or(0); // Ziel-Verzeichnis im App-Cache 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(); // Nur Basename aus URL extrahieren — keine Pfad-Traversal let file_name_raw = download_url.split('/').last().unwrap_or("update.AppImage"); let file_name = std::path::Path::new(file_name_raw) .file_name() .and_then(|f| f.to_str()) .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 hasher = Sha256::new(); 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))?; hasher.update(&chunk); downloaded += chunk.len() as u64; 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(); } // SHA256 verifizieren (wenn erwartet) if let Some(expected) = expected_sha256 { let actual = format!("{:x}", hasher.finalize()); if !actual.eq_ignore_ascii_case(&expected) { // kaputte Datei weg std::fs::remove_file(&download_path).ok(); return Err(format!( "SHA256-Mismatch: erwartet {}, berechnet {}", expected, actual )); } } // 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 + verifiziert: {:?}", download_path); Ok(download_path) } /// Wendet das Update an (ersetzt Ziel-Datei und startet neu). /// Unterstützte Modi: /// 1. AppImage-Runtime → `$APPIMAGE` zeigt auf die laufende AppImage-Datei /// 2. Nix-Wrapper-Modus → `$CLAUDE_DESKTOP_NIX_WRAPPER=1` + `$CLAUDE_DESKTOP_BIN` zeigt /// auf ~/.local/share/claude-desktop/bin/claude-desktop (writable, siehe nix/default.nix) /// 3. Entwicklungs-Build → Fehlerhinweis mit Build-Anleitung /// /// Vor dem Rename wird ein Smoke-Test durchgeführt: Die heruntergeladene Datei /// muss ein valides ELF-Binary sein (Magic-Bytes 0x7F 'ELF'). Das fängt korrupte /// Downloads und falsche Architekturen ab, bevor das funktionsfähige Binary ersetzt wird. #[tauri::command] pub async fn apply_update(app: AppHandle, update_path: PathBuf) -> Result<(), String> { // Modus bestimmen: AppImage > Nix-Wrapper > Dev let target_path = if let Ok(appimage) = std::env::var("APPIMAGE") { Some((PathBuf::from(appimage), "AppImage")) } else if std::env::var("CLAUDE_DESKTOP_NIX_WRAPPER").ok().as_deref() == Some("1") { std::env::var("CLAUDE_DESKTOP_BIN") .ok() .map(|p| (PathBuf::from(p), "Nix-Wrapper-Binary")) } else { None }; let (target, mode_label) = target_path.ok_or_else(|| { "Update nur für AppImage- oder Nix-Wrapper-Builds verfügbar.\n\ Für Dev-Builds: `git pull` und manuell neu bauen.\n\ Für Nix-System: `claude-desktop-install` nach dem Build ausführen." .to_string() })?; // === Smoke-Test: Ist die neue Datei ein valides ELF-Binary? === // ELF-Magic: 0x7F 'E' 'L' 'F' (gilt auch fuer AppImages, die self-extracting ELFs sind) let meta = std::fs::metadata(&update_path) .map_err(|e| format!("Update-Datei nicht zugreifbar: {}", e))?; if !meta.is_file() { return Err("Update-Datei ist keine regulaere Datei — Abbruch, bestehende Installation unveraendert.".into()); } if meta.len() < 4 { return Err(format!( "Update-Datei ist zu klein ({} Bytes) — vermutlich abgebrochener Download. Bestehende Installation unveraendert.", meta.len() )); } { use std::io::Read; let mut magic = [0u8; 4]; std::fs::File::open(&update_path) .and_then(|mut f| f.read_exact(&mut magic)) .map_err(|e| format!("Update-Datei nicht lesbar: {} — bestehende Installation unveraendert.", e))?; if magic != [0x7F, 0x45, 0x4C, 0x46] { return Err(format!( "Update-Datei ist kein ausfuehrbares ELF-Binary (Magic: {:02X} {:02X} {:02X} {:02X}). Bestehende Installation unveraendert.", magic[0], magic[1], magic[2], magic[3] )); } } // === Backup + Rename === let backup_path = { let mut p = target.clone(); let file_name = target .file_name() .and_then(|f| f.to_str()) .unwrap_or("claude-desktop"); p.set_file_name(format!("{}.backup", file_name)); p }; std::fs::rename(&target, &backup_path) .map_err(|e| format!("Backup fehlgeschlagen ({}): {}", mode_label, e))?; std::fs::rename(&update_path, &target).map_err(|e| { // Rollback std::fs::rename(&backup_path, &target).ok(); format!("Update-Installation fehlgeschlagen ({}): {} — Backup wiederhergestellt.", mode_label, e) })?; std::fs::remove_file(&backup_path).ok(); println!("✅ Update installiert ({}), starte neu...", mode_label); app.restart(); } /// Liefert die aktuelle Version (für UI-Anzeige) #[tauri::command] pub fn get_current_version() -> String { CURRENT_VERSION.to_string() } /// String-Vergleich für `YYYYMMDD-HHMM`-Versionen. /// Lexikographisch > ist bei Zeitstempel-Format korrekt. /// "dev" ist immer "nicht neuer". fn is_newer(candidate: &str, current: &str) -> bool { if candidate == "dev" || current == "dev" { return false; } candidate > current } #[cfg(test)] mod tests { use super::*; #[test] fn test_is_newer_timestamp() { assert!(is_newer("20260420-1201", "20260420-1200")); assert!(is_newer("20260421-0001", "20260420-2359")); assert!(!is_newer("20260420-1200", "20260420-1200")); assert!(!is_newer("20260420-1159", "20260420-1200")); } #[test] fn test_is_newer_dev_always_false() { assert!(!is_newer("dev", "20260420-1200")); assert!(!is_newer("20260420-1200", "dev")); assert!(!is_newer("dev", "dev")); } }