claude-desktop/src-tauri/src/update.rs
Eddy 29cce7fbd8
All checks were successful
Build AppImage / build (push) Successful in 6m37s
[appimage] UI-Polish: Icon, Stop-Button dezent, Chat-Queue, Update-Safety
- Neues Icon-Set (SVG-Quelle + gen-icon.sh): 32/64/128/256/512+@2x in depth=8
  (Tauri-Tray erwartet 8-bit-RGBA, depth=16 crashte den Tray-Setup)
- StopButton: Icon-only (⏹), Position Titlebar rechts, nur sichtbar wenn
  isProcessing aktiv. Kein full-width roter Balken im Footer mehr.
- .footer.active-Farbwechsel entfernt — Footer bleibt neutral
- Version-Badge in der Titlebar (v<APP_VERSION>)
- Chat-Input-Queue: Single-Slot-Puffer. Beim Senden waehrend Processing wird
  die Nachricht gepuffert, Pill "Nachricht wartet..." erscheint, nach Ende
  der aktuellen Antwort wird automatisch abgeschickt.
- Stop verwirft den gepufferten Slot (bewusster Abbruch).
- apply_update: ELF-Header-Smoke-Test vor Rename. Kaputter Download oder
  falsche Architektur liefert Fehlerdialog statt zerschossene Installation.

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

383 lines
14 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
///
/// 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"));
}
}