Feature: Auto-Update System für AppImage

- Backend (update.rs): Forgejo-API Check, Download mit Progress-Events, AppImage-Replace + Restart
- Frontend (UpdateDialog.svelte): Modal mit Version, Release-Notes, Fortschrittsbalken
- Automatischer Update-Check 3s nach App-Start
- reqwest mit stream-Feature für Download-Progress

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-15 14:06:23 +02:00
parent adcb8ac3b5
commit a519a7cdd2
7 changed files with 661 additions and 2 deletions

View file

@ -43,7 +43,23 @@
**Status:** Sub-Agents laufen auf Opus (inherit vom Main). Custom `agents`-Option in SDK scheint ignoriert zu werden bzw. spawnt Agents ohne Tools (halluziniert). **Status:** Sub-Agents laufen auf Opus (inherit vom Main). Custom `agents`-Option in SDK scheint ignoriert zu werden bzw. spawnt Agents ohne Tools (halluziniert).
**Nächster Ansatz:** Im Orchestrator-Prompt Claude explizit vorgeben `model: "haiku"` in Task-Calls zu setzen. Ob das SDK das respektiert, ist offen. **Nächster Ansatz:** Im Orchestrator-Prompt Claude explizit vorgeben `model: "haiku"` in Task-Calls zu setzen. Ob das SDK das respektiert, ist offen.
## Neue Features (15.04.2026)
### Auto-Update System
- **Backend** ([update.rs](src-tauri/src/update.rs)):
- `check_for_update()` — Prüft Forgejo `/repos/data/claude-desktop/releases`
- `download_update()` — Lädt AppImage mit Progress-Events herunter
- `apply_update()` — Backup, Replace, Restart via `app.restart()`
- **Frontend** ([UpdateDialog.svelte](src/lib/components/UpdateDialog.svelte)):
- Automatischer Check 3s nach App-Start
- Dialog mit Versions-Vergleich (v0.1.0 → v1.0.0)
- Release-Notes Anzeige
- Download-Fortschrittsbalken
- "Später" / "Jetzt aktualisieren" / "Jetzt installieren & neustarten"
- **Status**: Implementiert, wartet auf erstes Forgejo Release mit `.AppImage` Asset
## Letzte Commits ## Letzte Commits
- Feature: Auto-Update System für AppImage
- `f191cd0` Feature: Kontext-Auslastung im Footer (X% ctx) - `f191cd0` Feature: Kontext-Auslastung im Footer (X% ctx)
- `48fd61f` Fix: Auto-Session erscheint sofort in Session-Liste - `48fd61f` Fix: Auto-Session erscheint sofort in Session-Liste
- `a203589` Fix: Date-Panic in Wissensbasis (chrono::NaiveDateTime) - `a203589` Fix: Date-Panic in Wissensbasis (chrono::NaiveDateTime)

17
src-tauri/Cargo.lock generated
View file

@ -3571,12 +3571,14 @@ dependencies = [
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-native-tls", "tokio-native-tls",
"tokio-util",
"tower", "tower",
"tower-http", "tower-http",
"tower-service", "tower-service",
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys", "web-sys",
] ]
@ -3610,7 +3612,7 @@ dependencies = [
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams", "wasm-streams 0.5.0",
"web-sys", "web-sys",
] ]
@ -5489,6 +5491,19 @@ dependencies = [
"wasmparser", "wasmparser",
] ]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "wasm-streams" name = "wasm-streams"
version = "0.5.0" version = "0.5.0"

View file

@ -22,7 +22,7 @@ chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
rusqlite = { version = "0.31", features = ["bundled"] } rusqlite = { version = "0.31", features = ["bundled"] }
mysql_async = { version = "0.34", features = ["chrono"] } mysql_async = { version = "0.34", features = ["chrono"] }
reqwest = { version = "0.12", features = ["json", "multipart"] } reqwest = { version = "0.12", features = ["json", "multipart", "stream"] }
base64 = "0.22" base64 = "0.22"
tokio-tungstenite = "0.23" tokio-tungstenite = "0.23"
futures-util = "0.3" futures-util = "0.3"

View file

@ -20,6 +20,7 @@ mod memory;
mod programs; mod programs;
mod session; mod session;
mod teaching; mod teaching;
mod update;
mod voice; mod voice;
/// Initialisiert die App /// Initialisiert die App
@ -130,6 +131,11 @@ pub fn run() {
teaching::presentation_close, teaching::presentation_close,
teaching::presentation_send_slide, teaching::presentation_send_slide,
teaching::presentation_clear, teaching::presentation_clear,
// Auto-Update
update::check_for_update,
update::download_update,
update::apply_update,
update::get_current_version,
]) ])
.setup(|app| { .setup(|app| {
let handle = app.handle().clone(); let handle = app.handle().clone();

268
src-tauri/src/update.rs Normal file
View file

@ -0,0 +1,268 @@
// Claude Desktop — Auto-Update System
// Prüft Forgejo-Releases und aktualisiert das AppImage
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tauri::{AppHandle, Manager, Emitter};
/// Forgejo API Konfiguration
const FORGEJO_URL: &str = "https://git.data-it-solution.de";
const REPO_OWNER: &str = "data";
const REPO_NAME: &str = "claude-desktop";
/// Aktuelle App-Version (aus Cargo.toml)
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Release-Info von Forgejo
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReleaseInfo {
pub id: i64,
pub tag_name: String,
pub name: String,
pub body: Option<String>,
pub draft: bool,
pub prerelease: bool,
pub created_at: String,
pub published_at: Option<String>,
pub assets: Vec<ReleaseAsset>,
}
/// Release-Asset (z.B. AppImage)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReleaseAsset {
pub id: i64,
pub name: String,
pub size: i64,
pub download_count: i64,
pub browser_download_url: String,
}
/// Update-Status für 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>,
}
/// Prüft ob ein Update verfügbar ist
#[tauri::command]
pub async fn check_for_update() -> Result<UpdateStatus, String> {
let url = format!(
"{}/api/v1/repos/{}/{}/releases?limit=1",
FORGEJO_URL, REPO_OWNER, REPO_NAME
);
let client = reqwest::Client::new();
let response = client
.get(&url)
.header("Accept", "application/json")
.send()
.await
.map_err(|e| format!("Netzwerkfehler: {}", e))?;
if !response.status().is_success() {
return Ok(UpdateStatus {
available: false,
current_version: CURRENT_VERSION.to_string(),
latest_version: None,
release_notes: None,
download_url: None,
download_size: None,
});
}
let releases: Vec<ReleaseInfo> = response
.json()
.await
.map_err(|e| format!("JSON-Fehler: {}", e))?;
let latest = match releases.into_iter().find(|r| !r.draft && !r.prerelease) {
Some(r) => r,
None => {
return Ok(UpdateStatus {
available: false,
current_version: CURRENT_VERSION.to_string(),
latest_version: None,
release_notes: None,
download_url: None,
download_size: None,
});
}
};
// Version vergleichen (tag_name ohne 'v' Prefix)
let latest_version = latest.tag_name.trim_start_matches('v').to_string();
let is_newer = version_compare(&latest_version, CURRENT_VERSION);
// AppImage-Asset finden
let appimage = latest.assets.iter().find(|a| a.name.ends_with(".AppImage"));
Ok(UpdateStatus {
available: is_newer,
current_version: CURRENT_VERSION.to_string(),
latest_version: Some(latest_version),
release_notes: latest.body,
download_url: appimage.map(|a| a.browser_download_url.clone()),
download_size: appimage.map(|a| a.size),
})
}
/// Download-Fortschritt
#[derive(Debug, Clone, Serialize)]
pub struct DownloadProgress {
pub downloaded: u64,
pub total: u64,
pub percent: f32,
}
/// Lädt das Update herunter
#[tauri::command]
pub async fn download_update(
app: AppHandle,
download_url: String,
) -> Result<PathBuf, String> {
use std::io::Write;
let client = reqwest::Client::new();
let response = client
.get(&download_url)
.send()
.await
.map_err(|e| format!("Download-Fehler: {}", e))?;
let total_size = response.content_length().unwrap_or(0);
// Temp-Verzeichnis für Download
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();
let file_name = download_url.split('/').last().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 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))?;
downloaded += chunk.len() as u64;
// Fortschritt an Frontend senden
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();
}
// 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: {:?}", download_path);
Ok(download_path)
}
/// Wendet das Update an (ersetzt AppImage und startet neu)
#[tauri::command]
pub async fn apply_update(app: AppHandle, update_path: PathBuf) -> Result<(), String> {
// Prüfen ob wir in einem AppImage laufen
let appimage_path = std::env::var("APPIMAGE").ok();
if let Some(appimage) = appimage_path {
let appimage_path = PathBuf::from(&appimage);
// Backup erstellen
let backup_path = appimage_path.with_extension("AppImage.backup");
std::fs::rename(&appimage_path, &backup_path)
.map_err(|e| format!("Backup fehlgeschlagen: {}", e))?;
// Neues AppImage verschieben
std::fs::rename(&update_path, &appimage_path)
.map_err(|e| {
// Backup wiederherstellen bei Fehler
std::fs::rename(&backup_path, &appimage_path).ok();
format!("Update-Installation fehlgeschlagen: {}", e)
})?;
// Backup löschen
std::fs::remove_file(&backup_path).ok();
println!("✅ Update installiert, starte neu...");
// App neustarten (kehrt nicht zurück)
app.restart();
}
// Nicht in AppImage — Entwicklungsmodus
Err("Update nur für AppImage-Builds verfügbar. In Entwicklung: manuell `git pull` und neu bauen.".to_string())
}
/// Gibt aktuelle Version zurück
#[tauri::command]
pub fn get_current_version() -> String {
CURRENT_VERSION.to_string()
}
/// Vergleicht zwei Versionen (semver-ähnlich)
fn version_compare(new: &str, current: &str) -> bool {
let parse = |v: &str| -> Vec<u32> {
v.split('.')
.filter_map(|s| s.parse().ok())
.collect()
};
let new_parts = parse(new);
let current_parts = parse(current);
for i in 0..3 {
let n = new_parts.get(i).copied().unwrap_or(0);
let c = current_parts.get(i).copied().unwrap_or(0);
if n > c {
return true;
}
if n < c {
return false;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version_compare() {
assert!(version_compare("1.0.1", "1.0.0"));
assert!(version_compare("1.1.0", "1.0.9"));
assert!(version_compare("2.0.0", "1.9.9"));
assert!(!version_compare("1.0.0", "1.0.0"));
assert!(!version_compare("1.0.0", "1.0.1"));
assert!(!version_compare("0.9.0", "1.0.0"));
}
}

View file

@ -0,0 +1,350 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
interface UpdateStatus {
available: boolean;
current_version: string;
latest_version: string | null;
release_notes: string | null;
download_url: string | null;
download_size: number | null;
}
interface DownloadProgress {
downloaded: number;
total: number;
percent: number;
}
let showDialog = false;
let updateInfo: UpdateStatus | null = null;
let downloading = false;
let progress: DownloadProgress | null = null;
let error: string | null = null;
let downloadedPath: string | null = null;
let progressListener: UnlistenFn | null = null;
onMount(async () => {
// Update-Check beim Start (mit kurzer Verzögerung)
setTimeout(checkForUpdate, 3000);
// Progress-Events vom Backend
progressListener = await listen<DownloadProgress>('update-progress', (event) => {
progress = event.payload;
});
});
onDestroy(() => {
progressListener?.();
});
async function checkForUpdate() {
try {
const status: UpdateStatus = await invoke('check_for_update');
if (status.available) {
updateInfo = status;
showDialog = true;
console.log('🔄 Update verfügbar:', status.latest_version);
}
} catch (err) {
console.debug('Update-Check fehlgeschlagen:', err);
}
}
async function startDownload() {
if (!updateInfo?.download_url) return;
downloading = true;
error = null;
progress = { downloaded: 0, total: updateInfo.download_size || 0, percent: 0 };
try {
downloadedPath = await invoke('download_update', {
downloadUrl: updateInfo.download_url,
});
console.log('✅ Download abgeschlossen:', downloadedPath);
} catch (err) {
error = String(err);
downloading = false;
}
}
async function applyUpdate() {
if (!downloadedPath) return;
try {
await invoke('apply_update', { updatePath: downloadedPath });
// App wird neugestartet, kein weiterer Code erreicht
} catch (err) {
error = String(err);
}
}
function closeDialog() {
showDialog = false;
downloading = false;
progress = null;
error = null;
downloadedPath = null;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
</script>
{#if showDialog && updateInfo}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" on:click={closeDialog}>
<div class="modal" on:click|stopPropagation>
<div class="modal-header">
<h2>🔄 Update verfügbar</h2>
<button class="close-btn" on:click={closeDialog}>✕</button>
</div>
<div class="modal-body">
<div class="version-info">
<span class="current">v{updateInfo.current_version}</span>
<span class="arrow"></span>
<span class="new">v{updateInfo.latest_version}</span>
</div>
{#if updateInfo.release_notes}
<div class="release-notes">
<h3>Änderungen:</h3>
<div class="notes-content">
{@html updateInfo.release_notes.replace(/\n/g, '<br>')}
</div>
</div>
{/if}
{#if updateInfo.download_size}
<p class="download-size">
Download: {formatSize(updateInfo.download_size)}
</p>
{/if}
{#if downloading && progress}
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill" style="width: {progress.percent}%"></div>
</div>
<span class="progress-text">
{formatSize(progress.downloaded)} / {formatSize(progress.total)}
({progress.percent.toFixed(0)}%)
</span>
</div>
{/if}
{#if error}
<div class="error">
{error}
</div>
{/if}
</div>
<div class="modal-footer">
{#if downloadedPath}
<button class="btn btn-primary" on:click={applyUpdate}>
Jetzt installieren & neustarten
</button>
{:else if downloading}
<button class="btn btn-disabled" disabled>
Wird heruntergeladen...
</button>
{:else}
<button class="btn btn-secondary" on:click={closeDialog}>
Später
</button>
<button class="btn btn-primary" on:click={startDownload}>
Jetzt aktualisieren
</button>
{/if}
</div>
</div>
</div>
{/if}
<style>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
width: 90%;
max-width: 450px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-md);
border-bottom: 1px solid var(--border);
}
.modal-header h2 {
font-size: 1rem;
margin: 0;
}
.close-btn {
background: none;
border: none;
font-size: 1.2rem;
cursor: pointer;
color: var(--text-secondary);
padding: 4px;
}
.close-btn:hover {
color: var(--text-primary);
}
.modal-body {
padding: var(--spacing-md);
}
.version-info {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-md);
font-size: 1.1rem;
margin-bottom: var(--spacing-md);
}
.version-info .current {
color: var(--text-secondary);
}
.version-info .arrow {
color: var(--accent);
}
.version-info .new {
color: var(--success);
font-weight: 600;
}
.release-notes {
background: var(--bg-secondary);
border-radius: var(--radius-md);
padding: var(--spacing-sm);
margin-bottom: var(--spacing-md);
max-height: 150px;
overflow-y: auto;
}
.release-notes h3 {
font-size: 0.8rem;
color: var(--text-secondary);
margin: 0 0 var(--spacing-xs) 0;
}
.notes-content {
font-size: 0.85rem;
line-height: 1.5;
}
.download-size {
font-size: 0.8rem;
color: var(--text-secondary);
margin-bottom: var(--spacing-md);
}
.progress-container {
margin: var(--spacing-md) 0;
}
.progress-bar {
height: 8px;
background: var(--bg-tertiary);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent);
transition: width 0.2s ease;
}
.progress-text {
display: block;
text-align: center;
font-size: 0.75rem;
color: var(--text-secondary);
margin-top: var(--spacing-xs);
}
.error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
padding: var(--spacing-sm);
border-radius: var(--radius-md);
font-size: 0.85rem;
margin-top: var(--spacing-md);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
padding: var(--spacing-md);
border-top: 1px solid var(--border);
}
.btn {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-primary {
background: var(--accent);
color: white;
border: none;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-secondary {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--bg-hover);
}
.btn-disabled {
background: var(--bg-tertiary);
color: var(--text-secondary);
border: none;
cursor: not-allowed;
}
</style>

View file

@ -4,6 +4,7 @@
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { isProcessing, agentCount, currentModel, sessionStats, contextPercent, contextUsage, initEventListeners, cleanupEventListeners, currentSessionId, setMessagesFromDb, stickyContextInfo, agentMode, type DbMessage, type StickyContextInfo, type AgentMode } from '$lib/stores'; import { isProcessing, agentCount, currentModel, sessionStats, contextPercent, contextUsage, initEventListeners, cleanupEventListeners, currentSessionId, setMessagesFromDb, stickyContextInfo, agentMode, type DbMessage, type StickyContextInfo, type AgentMode } from '$lib/stores';
import StopButton from '$lib/components/StopButton.svelte'; import StopButton from '$lib/components/StopButton.svelte';
import UpdateDialog from '$lib/components/UpdateDialog.svelte';
// Session-Typ vom Backend // Session-Typ vom Backend
interface Session { interface Session {
@ -206,6 +207,9 @@
</footer> </footer>
</div> </div>
<!-- Auto-Update Dialog -->
<UpdateDialog />
<style> <style>
.app-container { .app-container {
display: flex; display: flex;