// 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}; /// Pfad zur Lock-Datei (verhindert doppelte Instanzen, wird vor Update entfernt) const LOCK_FILE_PATH: &str = "/tmp/claude-desktop.lock"; // ============ Lock-Datei System ============ /// Erstellt die Lock-Datei mit der aktuellen PID. /// Wenn bereits eine Instanz läuft, wird eine Warnung geloggt. /// Fehler beim Erstellen werden toleriert (App startet trotzdem). pub fn create_lock_file() { // Prüfen ob bereits eine andere Instanz läuft if check_lock_file() { if let Ok(content) = std::fs::read_to_string(LOCK_FILE_PATH) { eprintln!( "⚠️ Lock-Datei existiert und Prozess {} lebt noch — möglicherweise zweite Instanz!", content.trim() ); } } else if std::path::Path::new(LOCK_FILE_PATH).exists() { // Stale Lock vom letzten Crash — proaktiv aufräumen + protokollieren if let Ok(content) = std::fs::read_to_string(LOCK_FILE_PATH) { println!("🧹 Stale Lock-Datei aus vorherigem Crash gefunden (PID {}) — wird ersetzt", content.trim()); } } // PID in Lock-Datei schreiben let pid = std::process::id(); match std::fs::write(LOCK_FILE_PATH, pid.to_string()) { Ok(_) => println!("🔒 Lock-Datei erstellt: {} (PID {})", LOCK_FILE_PATH, pid), Err(e) => eprintln!( "⚠️ Lock-Datei konnte nicht erstellt werden: {} — App läuft trotzdem weiter", e ), } } /// Prüft ob die Lock-Datei existiert UND ob der darin gespeicherte Prozess noch lebt. /// Gibt true zurück wenn ein anderer lebender Prozess die Lock hält. pub fn check_lock_file() -> bool { let content = match std::fs::read_to_string(LOCK_FILE_PATH) { Ok(c) => c, Err(_) => return false, // Keine Lock-Datei → kein Problem }; let pid: u32 = match content.trim().parse() { Ok(p) => p, Err(_) => { // Ungültige PID in Lock-Datei → aufräumen std::fs::remove_file(LOCK_FILE_PATH).ok(); return false; } }; // Eigene PID → wir sind es selbst, kein Konflikt if pid == std::process::id() { return false; } // Prüfen ob der Prozess mit dieser PID noch existiert via /proc/{pid}/ let proc_path = format!("/proc/{}", pid); std::path::Path::new(&proc_path).exists() } /// Entfernt die Lock-Datei (Aufräumen bei App-Ende oder vor Update). pub fn remove_lock_file() { match std::fs::remove_file(LOCK_FILE_PATH) { Ok(_) => println!("🔓 Lock-Datei entfernt: {}", LOCK_FILE_PATH), Err(e) if e.kind() == std::io::ErrorKind::NotFound => { // Bereits entfernt — kein Problem } Err(e) => eprintln!("⚠️ Lock-Datei konnte nicht entfernt werden: {}", e), } } /// Bereitet die App auf ein Update vor: /// 1. Event an Frontend senden (UI kann State speichern) /// 2. Kurz warten damit Frontend reagieren kann /// 3. Lock-Datei entfernen async fn prepare_for_update(app: &AppHandle) -> Result<(), String> { // Frontend informieren app.emit("update-preparing", ()) .map_err(|e| format!("Konnte update-preparing Event nicht senden: {}", e))?; // 2 Sekunden warten — Frontend kann State/Sessions speichern tokio::time::sleep(std::time::Duration::from_secs(2)).await; // Lock-Datei entfernen bevor Binary ersetzt wird remove_lock_file(); Ok(()) } /// 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 wird trotzdem geprüft (jede Remote-Version gilt als neuer). 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, Scripts + node_modules sind darin enthalten). /// `binary_filename`/`binary_sha256` → pures Binary fuer Nix-Wrapper-Installation. /// `bundle_filename`/`bundle_sha256` → tar.gz mit scripts/ + package.json + package-lock.json /// fuer Nix-Wrapper (npm ci --omit=dev laeuft beim Install/Update). /// Aeltere Manifest-Versionen ohne diese 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, #[serde(default)] pub bundle_filename: Option, #[serde(default)] pub bundle_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, /// Bundle-URL (scripts + package.json tar.gz) — nur gesetzt im Nix-Wrapper-Modus pub bundle_url: Option, pub bundle_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. /// Auch Dev-Builds prüfen — jede Remote-Version gilt dann als neuer. #[tauri::command] pub async fn check_for_update() -> Result { let client = reqwest::Client::new(); let response = match authed_get(&client, UPDATE_JSON_URL).send().await { Ok(r) => r, Err(e) => { // Netzwerkfehler → kein Crash, einfach "kein Update gefunden" return Ok(UpdateStatus { available: false, current_version: CURRENT_VERSION.to_string(), latest_version: None, release_notes: Some(format!("Update-Check fehlgeschlagen (Netzwerk): {}", e)), download_url: None, download_size: None, sha256: None, bundle_url: None, bundle_sha256: None, }); } }; if !response.status().is_success() { // 401/403 ohne Token oder mit falschem Token → kein Crash, nur Hinweis return Ok(UpdateStatus { available: false, current_version: CURRENT_VERSION.to_string(), latest_version: None, release_notes: Some(format!( "Update-Check fehlgeschlagen (HTTP {}). Token konfiguriert: {}", response.status(), UPDATE_TOKEN.is_some() )), download_url: None, download_size: None, sha256: None, bundle_url: None, bundle_sha256: None, }); } 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, bundle_url: None, bundle_sha256: None, }); } } } else { (manifest.filename, manifest.sha256) }; let download_url = format!("{}{}", PACKAGE_BASE_URL, chosen_filename); // Bundle (scripts + package.json tar.gz) nur im Nix-Wrapper-Modus relevant let (bundle_url, bundle_sha256) = if is_nix_wrapper() { match (&manifest.bundle_filename, &manifest.bundle_sha256) { (Some(f), Some(h)) => ( Some(format!("{}{}", PACKAGE_BASE_URL, f)), Some(h.clone()), ), _ => (None, None), } } else { (None, None) }; 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), bundle_url, bundle_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(); } // Finales 100%-Event senden damit die UI den Balken voll anzeigt let final_progress = DownloadProgress { downloaded, total: if total_size > 0 { total_size } else { downloaded }, percent: 100.0, }; app.emit("update-progress", &final_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] )); } } // === Graceful Shutdown vorbereiten === prepare_for_update(&app).await?; // === 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 }; // Sicherheitshalber prüfen: Lock-Datei sollte bereits entfernt sein if std::path::Path::new(LOCK_FILE_PATH).exists() { remove_lock_file(); // Kurz warten damit Dateisystem synchronisiert tokio::time::sleep(std::time::Duration::from_millis(100)).await; } 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() } /// Lädt das Scripts-Bundle (tar.gz mit scripts/ + package.json + package-lock.json) herunter, /// verifiziert SHA256, entpackt nach ~/.local/share/claude-desktop/ und führt npm ci --omit=dev aus. /// /// Wird im Nix-Wrapper-Modus parallel zum Binary-Update benötigt, damit die claude-bridge.js /// mit ihren node_modules neben dem Binary liegt (bin/../scripts/claude-bridge.js). #[tauri::command] pub async fn apply_bundle_update( app: AppHandle, bundle_url: String, expected_sha256: Option, ) -> Result<(), String> { use std::io::Write; // Nur im Nix-Wrapper-Modus relevant — im AppImage sind Scripts+node_modules eh drin if !is_nix_wrapper() { return Ok(()); } let target_dir = std::env::var("HOME") .map(|h| PathBuf::from(h).join(".local/share/claude-desktop")) .map_err(|e| format!("$HOME nicht gesetzt: {}", e))?; std::fs::create_dir_all(&target_dir) .map_err(|e| format!("Ziel-Verzeichnis konnte nicht angelegt werden: {}", e))?; // === 1. Bundle herunterladen === app.emit("bundle-progress", "download").ok(); let client = reqwest::Client::new(); let response = match UPDATE_TOKEN { Some(token) => { let creds = B64.encode(format!("{}:{}", UPDATE_USER, token)); client .get(&bundle_url) .header("Authorization", format!("Basic {}", creds)) } None => client.get(&bundle_url), } .send() .await .map_err(|e| format!("Bundle-Download-Fehler: {}", e))?; if !response.status().is_success() { return Err(format!("Bundle-HTTP-Fehler: {}", response.status())); } let bundle_tmp = std::env::temp_dir().join(format!("claude-desktop-bundle-{}.tar.gz", std::process::id())); let bytes = response .bytes() .await .map_err(|e| format!("Bundle-Body-Fehler: {}", e))?; // SHA256 pruefen if let Some(expected) = expected_sha256 { let mut hasher = Sha256::new(); hasher.update(&bytes); let actual = format!("{:x}", hasher.finalize()); if !actual.eq_ignore_ascii_case(&expected) { return Err(format!( "Bundle-SHA256-Mismatch: erwartet {}, bekommen {}", expected, actual )); } } std::fs::File::create(&bundle_tmp) .and_then(|mut f| f.write_all(&bytes)) .map_err(|e| format!("Bundle-Zwischendatei: {}", e))?; // === 2. Entpacken === app.emit("bundle-progress", "extract").ok(); let status = std::process::Command::new("tar") .arg("-xzf") .arg(&bundle_tmp) .arg("-C") .arg(&target_dir) .status() .map_err(|e| format!("tar konnte nicht gestartet werden: {}", e))?; if !status.success() { return Err(format!("tar-Extract schlug fehl (exit {})", status)); } std::fs::remove_file(&bundle_tmp).ok(); // === 3. npm ci --omit=dev === app.emit("bundle-progress", "npm-install").ok(); let output = std::process::Command::new("npm") .arg("ci") .arg("--omit=dev") .current_dir(&target_dir) .output() .map_err(|e| format!("npm konnte nicht gestartet werden: {}", e))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!( "npm ci --omit=dev schlug fehl (exit {}): {}", output.status, stderr.lines().take(10).collect::>().join("\n") )); } app.emit("bundle-progress", "done").ok(); println!("✅ Bundle aktualisiert in {:?}", target_dir); Ok(()) } /// Prüft ob eine Version ein gültiges Zeitstempel-Format hat (nur Ziffern und Bindestriche). /// Beispiel: "20260420-1300" → true, "dev" → false, "dev-local" → false fn is_timestamp_version(v: &str) -> bool { !v.is_empty() && v.chars().all(|c| c.is_ascii_digit() || c == '-') } /// String-Vergleich für `YYYYMMDD-HHMM`-Versionen. /// Lexikographisch > ist bei Zeitstempel-Format korrekt. /// Wenn die lokale Version kein gültiger Zeitstempel ist (z.B. "dev", "dev-local"), /// gilt jede Remote-Version als neuer → Update immer anbieten. fn is_newer(candidate: &str, current: &str) -> bool { // Remote-Version muss ein gültiger Zeitstempel sein if !is_timestamp_version(candidate) { return false; } // Lokale Version kein Zeitstempel → jede gültige Remote-Version ist neuer if !is_timestamp_version(current) { return true; } // Beide gültig → lexikographischer Vergleich 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_local_immer_update() { // Lokale Version "dev" oder "dev-local" → jede gültige Remote-Version ist neuer assert!(is_newer("20260420-1200", "dev")); assert!(is_newer("20260420-1200", "dev-local")); assert!(is_newer("20260101-0000", "dev")); } #[test] fn test_is_newer_remote_dev_nie() { // Remote-Version "dev" → niemals als neuer behandeln assert!(!is_newer("dev", "20260420-1200")); assert!(!is_newer("dev", "dev")); assert!(!is_newer("dev-local", "dev")); } #[test] fn test_is_timestamp_version() { assert!(is_timestamp_version("20260420-1300")); assert!(is_timestamp_version("20260101-0000")); assert!(!is_timestamp_version("dev")); assert!(!is_timestamp_version("dev-local")); assert!(!is_timestamp_version("v1.0.0")); assert!(!is_timestamp_version("")); } }