claude-desktop/src-tauri/src/update.rs
Eddy a519a7cdd2 Feature: Auto-Update System für AppImage
- Backend (update.rs): Forgejo-API Check, Download mit Progress-Events, AppImage-Replace + Restart
- Frontend (UpdateDialog.svelte): Modal mit Version, Release-Notes, Fortschrittsbalken
- Automatischer Update-Check 3s nach App-Start
- reqwest mit stream-Feature für Download-Progress

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-15 14:06:23 +02:00

268 lines
7.9 KiB
Rust

// 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<String>,
pub draft: bool,
pub prerelease: bool,
pub created_at: String,
pub published_at: Option<String>,
pub assets: Vec<ReleaseAsset>,
}
/// 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<String>,
pub release_notes: Option<String>,
pub download_url: Option<String>,
pub download_size: Option<i64>,
}
/// Prüft ob ein Update verfügbar ist
#[tauri::command]
pub async fn check_for_update() -> Result<UpdateStatus, String> {
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<ReleaseInfo> = 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<PathBuf, String> {
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<u32> {
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"));
}
}