All checks were successful
Build AppImage / build (push) Successful in 6m37s
- 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>
383 lines
14 KiB
Rust
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"));
|
|
}
|
|
}
|