claude-desktop/src-tauri/src/update.rs
Eddy 506f1d3fdc
All checks were successful
Build AppImage / build (push) Successful in 7m52s
[appimage] Auto-Updater: Package Registry + update.json + Nix-Wrapper
- update.rs: Umstellung auf Package-Registry-Manifest mit SHA256-Verify,
  Basic-Auth, dev/APPIMAGE/Nix-Wrapper-Modus. Liest binary_filename
  im Nix-Modus (AppImage laeuft auf NixOS nicht)
- Nix-Wrapper-Paket (nix/default.nix): LD_LIBRARY_PATH-korrekter Launcher
  + Installer-Script, User-Home-Binary (writable fuer Auto-Update)
- CI laedt jetzt AppImage UND natives Binary + update.json v2
  (binary_filename/binary_sha256) in die Package Registry
- Svelte: Store-basierter Update-Trigger, manueller Check im
  Settings-Panel, "Kein Update"-Dialog-Variante, expectedSha256-Param
- install.sh: One-Click-Installer fuer NixOS (curl | bash)
- sha2-Dep fuer Integritaets-Check des Downloads

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:05:19 +02:00

352 lines
12 KiB
Rust

// 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<String>,
#[serde(default)]
pub binary_sha256: Option<String>,
pub notes: Option<String>,
pub released_at: Option<String>,
}
/// 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<String>,
pub release_notes: Option<String>,
pub download_url: Option<String>,
pub download_size: Option<i64>,
pub sha256: Option<String>,
}
/// 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<UpdateStatus, String> {
// 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<String>,
) -> Result<PathBuf, String> {
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
#[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()
})?;
// Backup-Pfad: gleiches Verzeichnis, Suffix .backup
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 ({}): {}", 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"));
}
}