All checks were successful
Build AppImage / build (push) Successful in 8m20s
Crash-Fix: - src/db.rs:801 panickte mit "byte index 240 is not a char boundary" mitten in einem ✅-Emoji → SIGABRT. Neues strutil-Modul mit safe_truncate()/safe_truncate_ellipsis() (5 Tests grün), an allen &s[..N]-Stellen in db/claude/knowledge/session/memory.rs eingebaut. - update.rs: Stale Lock-Files vom letzten Crash werden jetzt protokolliert ("🧹 Stale Lock-Datei aus vorherigem Crash gefunden"). Chat-Polish: - Input-Textfeld wird nach Senden zuverlässig geleert (Store-Reset + DOM-Reset + tick — Svelte 5 bind:value mit Auto-Subscription aktualisiert sonst nicht synchron). - ApprovalBar.svelte (NEU): Sticky-Bar überm Input mit klar beschrifteten Buttons "Übernehmen"/"Verwerfen" statt mehrdeutigem "Behalten/Zurueck". Bleibt sichtbar wenn der Chat scrollt. Klick auf Datei-Name scrollt zur Inline-Karte und blinkt sie. Shortcuts Ctrl+Enter/Ctrl+Backspace. - MessageList: Auto-Scroll trackt jetzt auch toolCalls.length und Status-Änderungen, plus ResizeObserver am Container. Smooth bei kleinen Distanzen, instant bei großen. - Streaming-Caret: pulsierender Block-Cursor mit Glow-Shadow. - Tool-Cards: Slide-In-Transition + Shimmer-Animation auf running. - WorkingIndicator: Verb passt sich an processingPhase an.
665 lines
24 KiB
Rust
665 lines
24 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};
|
|
|
|
/// Pfad zur Lock-Datei (verhindert doppelte Instanzen, wird vor Update entfernt)
|
|
const LOCK_FILE_PATH: &str = "/tmp/claude-desktop.lock";
|
|
|
|
// ============ Lock-Datei System ============
|
|
|
|
/// Erstellt die Lock-Datei mit der aktuellen PID.
|
|
/// Wenn bereits eine Instanz läuft, wird eine Warnung geloggt.
|
|
/// Fehler beim Erstellen werden toleriert (App startet trotzdem).
|
|
pub fn create_lock_file() {
|
|
// Prüfen ob bereits eine andere Instanz läuft
|
|
if check_lock_file() {
|
|
if let Ok(content) = std::fs::read_to_string(LOCK_FILE_PATH) {
|
|
eprintln!(
|
|
"⚠️ Lock-Datei existiert und Prozess {} lebt noch — möglicherweise zweite Instanz!",
|
|
content.trim()
|
|
);
|
|
}
|
|
} else if std::path::Path::new(LOCK_FILE_PATH).exists() {
|
|
// Stale Lock vom letzten Crash — proaktiv aufräumen + protokollieren
|
|
if let Ok(content) = std::fs::read_to_string(LOCK_FILE_PATH) {
|
|
println!("🧹 Stale Lock-Datei aus vorherigem Crash gefunden (PID {}) — wird ersetzt", content.trim());
|
|
}
|
|
}
|
|
|
|
// PID in Lock-Datei schreiben
|
|
let pid = std::process::id();
|
|
match std::fs::write(LOCK_FILE_PATH, pid.to_string()) {
|
|
Ok(_) => println!("🔒 Lock-Datei erstellt: {} (PID {})", LOCK_FILE_PATH, pid),
|
|
Err(e) => eprintln!(
|
|
"⚠️ Lock-Datei konnte nicht erstellt werden: {} — App läuft trotzdem weiter",
|
|
e
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Prüft ob die Lock-Datei existiert UND ob der darin gespeicherte Prozess noch lebt.
|
|
/// Gibt true zurück wenn ein anderer lebender Prozess die Lock hält.
|
|
pub fn check_lock_file() -> bool {
|
|
let content = match std::fs::read_to_string(LOCK_FILE_PATH) {
|
|
Ok(c) => c,
|
|
Err(_) => return false, // Keine Lock-Datei → kein Problem
|
|
};
|
|
|
|
let pid: u32 = match content.trim().parse() {
|
|
Ok(p) => p,
|
|
Err(_) => {
|
|
// Ungültige PID in Lock-Datei → aufräumen
|
|
std::fs::remove_file(LOCK_FILE_PATH).ok();
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// Eigene PID → wir sind es selbst, kein Konflikt
|
|
if pid == std::process::id() {
|
|
return false;
|
|
}
|
|
|
|
// Prüfen ob der Prozess mit dieser PID noch existiert via /proc/{pid}/
|
|
let proc_path = format!("/proc/{}", pid);
|
|
std::path::Path::new(&proc_path).exists()
|
|
}
|
|
|
|
/// Entfernt die Lock-Datei (Aufräumen bei App-Ende oder vor Update).
|
|
pub fn remove_lock_file() {
|
|
match std::fs::remove_file(LOCK_FILE_PATH) {
|
|
Ok(_) => println!("🔓 Lock-Datei entfernt: {}", LOCK_FILE_PATH),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
|
// Bereits entfernt — kein Problem
|
|
}
|
|
Err(e) => eprintln!("⚠️ Lock-Datei konnte nicht entfernt werden: {}", e),
|
|
}
|
|
}
|
|
|
|
/// Bereitet die App auf ein Update vor:
|
|
/// 1. Event an Frontend senden (UI kann State speichern)
|
|
/// 2. Kurz warten damit Frontend reagieren kann
|
|
/// 3. Lock-Datei entfernen
|
|
async fn prepare_for_update(app: &AppHandle) -> Result<(), String> {
|
|
// Frontend informieren
|
|
app.emit("update-preparing", ())
|
|
.map_err(|e| format!("Konnte update-preparing Event nicht senden: {}", e))?;
|
|
|
|
// 2 Sekunden warten — Frontend kann State/Sessions speichern
|
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
|
|
|
// Lock-Datei entfernen bevor Binary ersetzt wird
|
|
remove_lock_file();
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// 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 wird trotzdem geprüft (jede Remote-Version gilt als neuer).
|
|
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, Scripts + node_modules sind darin enthalten).
|
|
/// `binary_filename`/`binary_sha256` → pures Binary fuer Nix-Wrapper-Installation.
|
|
/// `bundle_filename`/`bundle_sha256` → tar.gz mit scripts/ + package.json + package-lock.json
|
|
/// fuer Nix-Wrapper (npm ci --omit=dev laeuft beim Install/Update).
|
|
/// Aeltere Manifest-Versionen ohne diese 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>,
|
|
#[serde(default)]
|
|
pub bundle_filename: Option<String>,
|
|
#[serde(default)]
|
|
pub bundle_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>,
|
|
/// Bundle-URL (scripts + package.json tar.gz) — nur gesetzt im Nix-Wrapper-Modus
|
|
pub bundle_url: Option<String>,
|
|
pub bundle_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.
|
|
/// Auch Dev-Builds prüfen — jede Remote-Version gilt dann als neuer.
|
|
#[tauri::command]
|
|
pub async fn check_for_update() -> Result<UpdateStatus, String> {
|
|
let client = reqwest::Client::new();
|
|
let response = match authed_get(&client, UPDATE_JSON_URL).send().await {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
// Netzwerkfehler → kein Crash, einfach "kein Update gefunden"
|
|
return Ok(UpdateStatus {
|
|
available: false,
|
|
current_version: CURRENT_VERSION.to_string(),
|
|
latest_version: None,
|
|
release_notes: Some(format!("Update-Check fehlgeschlagen (Netzwerk): {}", e)),
|
|
download_url: None,
|
|
download_size: None,
|
|
sha256: None,
|
|
bundle_url: None,
|
|
bundle_sha256: None,
|
|
});
|
|
}
|
|
};
|
|
|
|
if !response.status().is_success() {
|
|
// 401/403 ohne Token oder mit falschem Token → kein Crash, nur Hinweis
|
|
return Ok(UpdateStatus {
|
|
available: false,
|
|
current_version: CURRENT_VERSION.to_string(),
|
|
latest_version: None,
|
|
release_notes: Some(format!(
|
|
"Update-Check fehlgeschlagen (HTTP {}). Token konfiguriert: {}",
|
|
response.status(),
|
|
UPDATE_TOKEN.is_some()
|
|
)),
|
|
download_url: None,
|
|
download_size: None,
|
|
sha256: None,
|
|
bundle_url: None,
|
|
bundle_sha256: None,
|
|
});
|
|
}
|
|
|
|
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,
|
|
bundle_url: None,
|
|
bundle_sha256: None,
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
(manifest.filename, manifest.sha256)
|
|
};
|
|
|
|
let download_url = format!("{}{}", PACKAGE_BASE_URL, chosen_filename);
|
|
|
|
// Bundle (scripts + package.json tar.gz) nur im Nix-Wrapper-Modus relevant
|
|
let (bundle_url, bundle_sha256) = if is_nix_wrapper() {
|
|
match (&manifest.bundle_filename, &manifest.bundle_sha256) {
|
|
(Some(f), Some(h)) => (
|
|
Some(format!("{}{}", PACKAGE_BASE_URL, f)),
|
|
Some(h.clone()),
|
|
),
|
|
_ => (None, None),
|
|
}
|
|
} else {
|
|
(None, None)
|
|
};
|
|
|
|
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),
|
|
bundle_url,
|
|
bundle_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();
|
|
}
|
|
|
|
// Finales 100%-Event senden damit die UI den Balken voll anzeigt
|
|
let final_progress = DownloadProgress {
|
|
downloaded,
|
|
total: if total_size > 0 { total_size } else { downloaded },
|
|
percent: 100.0,
|
|
};
|
|
app.emit("update-progress", &final_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]
|
|
));
|
|
}
|
|
}
|
|
|
|
// === Graceful Shutdown vorbereiten ===
|
|
prepare_for_update(&app).await?;
|
|
|
|
// === 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
|
|
};
|
|
|
|
// Sicherheitshalber prüfen: Lock-Datei sollte bereits entfernt sein
|
|
if std::path::Path::new(LOCK_FILE_PATH).exists() {
|
|
remove_lock_file();
|
|
// Kurz warten damit Dateisystem synchronisiert
|
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
/// Lädt das Scripts-Bundle (tar.gz mit scripts/ + package.json + package-lock.json) herunter,
|
|
/// verifiziert SHA256, entpackt nach ~/.local/share/claude-desktop/ und führt npm ci --omit=dev aus.
|
|
///
|
|
/// Wird im Nix-Wrapper-Modus parallel zum Binary-Update benötigt, damit die claude-bridge.js
|
|
/// mit ihren node_modules neben dem Binary liegt (bin/../scripts/claude-bridge.js).
|
|
#[tauri::command]
|
|
pub async fn apply_bundle_update(
|
|
app: AppHandle,
|
|
bundle_url: String,
|
|
expected_sha256: Option<String>,
|
|
) -> Result<(), String> {
|
|
use std::io::Write;
|
|
|
|
// Nur im Nix-Wrapper-Modus relevant — im AppImage sind Scripts+node_modules eh drin
|
|
if !is_nix_wrapper() {
|
|
return Ok(());
|
|
}
|
|
|
|
let target_dir = std::env::var("HOME")
|
|
.map(|h| PathBuf::from(h).join(".local/share/claude-desktop"))
|
|
.map_err(|e| format!("$HOME nicht gesetzt: {}", e))?;
|
|
std::fs::create_dir_all(&target_dir)
|
|
.map_err(|e| format!("Ziel-Verzeichnis konnte nicht angelegt werden: {}", e))?;
|
|
|
|
// === 1. Bundle herunterladen ===
|
|
app.emit("bundle-progress", "download").ok();
|
|
let client = reqwest::Client::new();
|
|
let response = match UPDATE_TOKEN {
|
|
Some(token) => {
|
|
let creds = B64.encode(format!("{}:{}", UPDATE_USER, token));
|
|
client
|
|
.get(&bundle_url)
|
|
.header("Authorization", format!("Basic {}", creds))
|
|
}
|
|
None => client.get(&bundle_url),
|
|
}
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Bundle-Download-Fehler: {}", e))?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(format!("Bundle-HTTP-Fehler: {}", response.status()));
|
|
}
|
|
|
|
let bundle_tmp = std::env::temp_dir().join(format!("claude-desktop-bundle-{}.tar.gz", std::process::id()));
|
|
let bytes = response
|
|
.bytes()
|
|
.await
|
|
.map_err(|e| format!("Bundle-Body-Fehler: {}", e))?;
|
|
|
|
// SHA256 pruefen
|
|
if let Some(expected) = expected_sha256 {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(&bytes);
|
|
let actual = format!("{:x}", hasher.finalize());
|
|
if !actual.eq_ignore_ascii_case(&expected) {
|
|
return Err(format!(
|
|
"Bundle-SHA256-Mismatch: erwartet {}, bekommen {}",
|
|
expected, actual
|
|
));
|
|
}
|
|
}
|
|
|
|
std::fs::File::create(&bundle_tmp)
|
|
.and_then(|mut f| f.write_all(&bytes))
|
|
.map_err(|e| format!("Bundle-Zwischendatei: {}", e))?;
|
|
|
|
// === 2. Entpacken ===
|
|
app.emit("bundle-progress", "extract").ok();
|
|
let status = std::process::Command::new("tar")
|
|
.arg("-xzf")
|
|
.arg(&bundle_tmp)
|
|
.arg("-C")
|
|
.arg(&target_dir)
|
|
.status()
|
|
.map_err(|e| format!("tar konnte nicht gestartet werden: {}", e))?;
|
|
if !status.success() {
|
|
return Err(format!("tar-Extract schlug fehl (exit {})", status));
|
|
}
|
|
std::fs::remove_file(&bundle_tmp).ok();
|
|
|
|
// === 3. npm ci --omit=dev ===
|
|
app.emit("bundle-progress", "npm-install").ok();
|
|
let output = std::process::Command::new("npm")
|
|
.arg("ci")
|
|
.arg("--omit=dev")
|
|
.current_dir(&target_dir)
|
|
.output()
|
|
.map_err(|e| format!("npm konnte nicht gestartet werden: {}", e))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(format!(
|
|
"npm ci --omit=dev schlug fehl (exit {}): {}",
|
|
output.status,
|
|
stderr.lines().take(10).collect::<Vec<_>>().join("\n")
|
|
));
|
|
}
|
|
|
|
app.emit("bundle-progress", "done").ok();
|
|
println!("✅ Bundle aktualisiert in {:?}", target_dir);
|
|
Ok(())
|
|
}
|
|
|
|
/// Prüft ob eine Version ein gültiges Zeitstempel-Format hat (nur Ziffern und Bindestriche).
|
|
/// Beispiel: "20260420-1300" → true, "dev" → false, "dev-local" → false
|
|
fn is_timestamp_version(v: &str) -> bool {
|
|
!v.is_empty() && v.chars().all(|c| c.is_ascii_digit() || c == '-')
|
|
}
|
|
|
|
/// String-Vergleich für `YYYYMMDD-HHMM`-Versionen.
|
|
/// Lexikographisch > ist bei Zeitstempel-Format korrekt.
|
|
/// Wenn die lokale Version kein gültiger Zeitstempel ist (z.B. "dev", "dev-local"),
|
|
/// gilt jede Remote-Version als neuer → Update immer anbieten.
|
|
fn is_newer(candidate: &str, current: &str) -> bool {
|
|
// Remote-Version muss ein gültiger Zeitstempel sein
|
|
if !is_timestamp_version(candidate) {
|
|
return false;
|
|
}
|
|
// Lokale Version kein Zeitstempel → jede gültige Remote-Version ist neuer
|
|
if !is_timestamp_version(current) {
|
|
return true;
|
|
}
|
|
// Beide gültig → lexikographischer Vergleich
|
|
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_local_immer_update() {
|
|
// Lokale Version "dev" oder "dev-local" → jede gültige Remote-Version ist neuer
|
|
assert!(is_newer("20260420-1200", "dev"));
|
|
assert!(is_newer("20260420-1200", "dev-local"));
|
|
assert!(is_newer("20260101-0000", "dev"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_newer_remote_dev_nie() {
|
|
// Remote-Version "dev" → niemals als neuer behandeln
|
|
assert!(!is_newer("dev", "20260420-1200"));
|
|
assert!(!is_newer("dev", "dev"));
|
|
assert!(!is_newer("dev-local", "dev"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_timestamp_version() {
|
|
assert!(is_timestamp_version("20260420-1300"));
|
|
assert!(is_timestamp_version("20260101-0000"));
|
|
assert!(!is_timestamp_version("dev"));
|
|
assert!(!is_timestamp_version("dev-local"));
|
|
assert!(!is_timestamp_version("v1.0.0"));
|
|
assert!(!is_timestamp_version(""));
|
|
}
|
|
}
|