- 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>
268 lines
7.9 KiB
Rust
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"));
|
|
}
|
|
}
|