claude-desktop/src-tauri/src/update.rs
Eddy 79f4f9fb21
All checks were successful
Build AppImage / build (push) Successful in 8m20s
fix: UTF-8-Crash + Input-Reset + ApprovalBar + Scroll/Streaming-Polish [appimage]
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.
2026-04-27 20:55:08 +02:00

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(""));
}
}