[appimage] Auto-Updater: Package Registry + update.json + Nix-Wrapper
All checks were successful
Build AppImage / build (push) Successful in 7m52s

- update.rs: Umstellung auf Package-Registry-Manifest mit SHA256-Verify,
  Basic-Auth, dev/APPIMAGE/Nix-Wrapper-Modus. Liest binary_filename
  im Nix-Modus (AppImage laeuft auf NixOS nicht)
- Nix-Wrapper-Paket (nix/default.nix): LD_LIBRARY_PATH-korrekter Launcher
  + Installer-Script, User-Home-Binary (writable fuer Auto-Update)
- CI laedt jetzt AppImage UND natives Binary + update.json v2
  (binary_filename/binary_sha256) in die Package Registry
- Svelte: Store-basierter Update-Trigger, manueller Check im
  Settings-Panel, "Kein Update"-Dialog-Variante, expectedSha256-Param
- install.sh: One-Click-Installer fuer NixOS (curl | bash)
- sha2-Dep fuer Integritaets-Check des Downloads

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-20 11:05:19 +02:00
parent 4e36b04cc9
commit 506f1d3fdc
14 changed files with 963 additions and 236 deletions

View file

@ -28,10 +28,22 @@ jobs:
rustc --version
cargo --version
- name: App-Version festlegen
# Version EINMAL am Anfang festlegen (KB #160) — wird als Env an alle Folge-Steps durchgereicht
run: |
APP_VERSION="$(date +%Y%m%d-%H%M)"
echo "APP_VERSION=${APP_VERSION}" >> $GITHUB_ENV
echo "App-Version: ${APP_VERSION}"
- name: Install npm packages
run: npm ci
- name: Build Tauri App
env:
APP_VERSION: ${{ env.APP_VERSION }}
# Wird als Basic-Auth (user: "data") fuer /api/packages gebraucht.
# REGISTRY_TOKEN hat Package-Read-Rechte und ist im Repo bereits gesetzt.
UPDATE_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
npm run tauri build -- --bundles appimage
ls -la src-tauri/target/release/bundle/appimage/
@ -88,51 +100,100 @@ jobs:
ls -la "$BUNDLE_DIR/"
- name: Get Version
id: version
run: |
VERSION=$(grep '^version' src-tauri/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "Version: ${VERSION}"
- name: Upload to Package Registry
run: |
set -e
# --- AppImage vorbereiten ---
ORIG=$(ls src-tauri/target/release/bundle/appimage/*.AppImage | head -1)
# Tauri benennt mit "Claude Desktop_..." (Leerzeichen) -> URL-unsicher.
# Umbenennen zu "Claude-Desktop_..." vor dem Upload.
SAFE_NAME=$(basename "$ORIG" | tr ' ' '-')
APPIMAGE="$(dirname "$ORIG")/$SAFE_NAME"
mv "$ORIG" "$APPIMAGE"
FILENAME="$SAFE_NAME"
VERSION=$(grep '^version' src-tauri/Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
echo "Lade $FILENAME (v${VERSION}) in Package Registry..."
# --- Natives Binary fuer Nix-Wrapper-Installationen vorbereiten ---
# Cargo legt's unter target/release/claude-desktop (ohne CARGO_TARGET_DIR)
BINARY_SRC="src-tauri/target/release/claude-desktop"
if [ ! -x "$BINARY_SRC" ]; then
echo "❌ Erwartetes Binary nicht gefunden: $BINARY_SRC" >&2
exit 1
fi
BINARY_NAME="claude-desktop_${APP_VERSION}_linux-x86_64"
cp "$BINARY_SRC" "/tmp/${BINARY_NAME}"
echo "Lade $FILENAME + $BINARY_NAME (v${APP_VERSION}) in Package Registry..."
BASE="https://git.data-it-solution.de/api/packages/data/generic/claude-desktop"
# Latest + versionierte Datei loeschen falls vorhanden
# (Forgejo Package Registry weist PUT auf existierenden Pfad mit 409 ab)
curl -sS -X DELETE \
--user "data:${{ secrets.REGISTRY_TOKEN }}" \
"${BASE}/latest/${FILENAME}" >/dev/null 2>&1 || true
curl -sS -X DELETE \
--user "data:${{ secrets.REGISTRY_TOKEN }}" \
"${BASE}/${VERSION}/${FILENAME}" >/dev/null 2>&1 || true
# SHA256-Hashes fuer Integritaets-Check in update.json
SHA256=$(sha256sum "$APPIMAGE" | awk '{print $1}')
BINARY_SHA256=$(sha256sum "/tmp/${BINARY_NAME}" | awk '{print $1}')
NOTES=$(git log -1 --pretty=%s)
RELEASED_AT=$(date -Iseconds)
# Versioniert hochladen
# update.json-Manifest bauen — wird vom Client-Updater (update.rs) gelesen.
# binary_* werden im Nix-Wrapper-Modus verwendet (siehe nix/default.nix).
cat > /tmp/update.json <<EOF
{
"version": "${APP_VERSION}",
"filename": "${FILENAME}",
"sha256": "${SHA256}",
"binary_filename": "${BINARY_NAME}",
"binary_sha256": "${BINARY_SHA256}",
"notes": $(printf '%s' "$NOTES" | jq -Rs .),
"released_at": "${RELEASED_AT}"
}
EOF
echo "--- update.json ---"
cat /tmp/update.json
# Alte Dateien loeschen (Forgejo weist PUT auf existierende Pfade mit 409 ab)
for PATH_SUFFIX in \
"latest/${FILENAME}" "${APP_VERSION}/${FILENAME}" \
"latest/${BINARY_NAME}" "${APP_VERSION}/${BINARY_NAME}" \
"latest/update.json" "${APP_VERSION}/update.json"; do
curl -sS -X DELETE \
--user "data:${{ secrets.REGISTRY_TOKEN }}" \
"${BASE}/${PATH_SUFFIX}" >/dev/null 2>&1 || true
done
# AppImage versioniert + latest hochladen
curl --fail -sS -X PUT \
--user "data:${{ secrets.REGISTRY_TOKEN }}" \
--upload-file "$APPIMAGE" \
"${BASE}/${VERSION}/${FILENAME}"
"${BASE}/${APP_VERSION}/${FILENAME}"
# Latest hochladen
curl --fail -sS -X PUT \
--user "data:${{ secrets.REGISTRY_TOKEN }}" \
--upload-file "$APPIMAGE" \
"${BASE}/latest/${FILENAME}"
echo "Upload abgeschlossen: ${FILENAME} (v${VERSION})"
# Natives Binary versioniert + latest hochladen
curl --fail -sS -X PUT \
--user "data:${{ secrets.REGISTRY_TOKEN }}" \
--upload-file "/tmp/${BINARY_NAME}" \
"${BASE}/${APP_VERSION}/${BINARY_NAME}"
curl --fail -sS -X PUT \
--user "data:${{ secrets.REGISTRY_TOKEN }}" \
--upload-file "/tmp/${BINARY_NAME}" \
"${BASE}/latest/${BINARY_NAME}"
# update.json versioniert + latest hochladen
curl --fail -sS -X PUT \
--user "data:${{ secrets.REGISTRY_TOKEN }}" \
--upload-file /tmp/update.json \
"${BASE}/${APP_VERSION}/update.json"
curl --fail -sS -X PUT \
--user "data:${{ secrets.REGISTRY_TOKEN }}" \
--upload-file /tmp/update.json \
"${BASE}/latest/update.json"
echo "Upload abgeschlossen: ${FILENAME} + ${BINARY_NAME} (v${APP_VERSION})"
echo " AppImage SHA256: ${SHA256}"
echo " Binary SHA256: ${BINARY_SHA256}"
- name: Upload to Release
if: startsWith(github.ref, 'refs/tags/v')

4
.gitignore vendored
View file

@ -10,6 +10,10 @@ dist/
src-tauri/target/
src-tauri/gen/
# Nix build outputs
result
result-*
# IDE
.vscode/
.idea/

70
BUILD-NIXOS.md Normal file
View file

@ -0,0 +1,70 @@
# Claude Desktop — Native Build auf NixOS
Anleitung für den nativen Produktiv-Build auf NixOS. **Das CI-AppImage funktioniert auf NixOS nicht** (WebKit2GTK/Mesa EGL-ABI-Konflikt, siehe KB #381) — deshalb wird hier immer nativ über die Nix-Dev-Shell gebaut.
## Voraussetzungen
- NixOS-System mit `nix-shell` im PATH
- `shell.nix` im Projekt-Root (liefert Rust, Node 22, WebKitGTK 4.1, GTK3, OpenSSL, libsoup3 etc.)
- Projekt liegt auf SMB-Mount — `/mnt/17 - Entwicklungen/20 - Projekte/ClaudeDesktop`
## Warum `CARGO_TARGET_DIR=/tmp/...`?
Das Projekt liegt auf einem SMB-Share. Cargo kann dort nicht zuverlässig schreiben (I/O-Errors bei Lockfiles, Rename-Operationen scheitern). Deshalb muss das Build-Target auf ein lokales Dateisystem (tmpfs/ext4) umgeleitet werden. Siehe KB #382.
## Build-Befehl (der Pflicht-Weg)
```bash
CARGO_TARGET_DIR=/tmp/claude-target \
nix-shell shell.nix --run 'npm ci && npm run tauri build -- --bundles appimage'
```
Phasen:
1. `npm ci` — Frontend-Abhängigkeiten (überspringbar wenn `node_modules/` aktuell ist)
2. SvelteKit-Build — produziert `build/` (Vite)
3. Cargo-Build `--release` — kompiliert Rust-Binary in `/tmp/claude-target/release/claude-desktop`
4. Tauri-Bundle (AppImage) — **crasht auf NixOS oft in der pkg-config-Phase von `linuxdeploy`**
> Das Bundling darf scheitern — das Binary aus Phase 3 ist trotzdem lauffähig.
## Binary starten
```bash
nix-shell shell.nix --run /tmp/claude-target/release/claude-desktop
```
Der Start muss durch `nix-shell` laufen, damit `LD_LIBRARY_PATH` korrekt gesetzt ist (WebKitGTK, GTK3 etc.).
## Inkrementelle Builds
Nach Code-Änderungen reicht:
```bash
CARGO_TARGET_DIR=/tmp/claude-target \
nix-shell shell.nix --run 'npm run tauri build -- --bundles appimage'
```
- Nur Rust geändert → Cargo baut inkrementell neu (~1060 s)
- Nur Frontend geändert → Vite+Cargo linken neu (~30 s)
- Abhängigkeiten neu → erster Cargo-Build dauert 515 min
## Typische Fehler
| Symptom | Ursache | Fix |
|---|---|---|
| `error: could not write to ...` | Cargo auf SMB | `CARGO_TARGET_DIR=/tmp/...` vorne anstellen |
| `pkg-config exited with status code 1` am Ende | Tauri-Bundler-Bug bei AppImage auf NixOS | ignorieren, Binary ist fertig |
| `EGL_BAD_PARAMETER` beim Start | AppImage statt nativem Binary gestartet | `/tmp/claude-target/release/claude-desktop` direkt starten |
| `webkit2gtk-4.1 not found` | außerhalb von `nix-shell` | Start immer via `nix-shell shell.nix --run …` |
| `rustc: command not found` | außerhalb von `nix-shell` | siehe oben |
## Hot-Reload-Dev-Mode (Alternative)
Für UI-Entwicklung reicht meist der Dev-Modus — kein Release-Build nötig:
```bash
nix-shell shell.nix --run 'npm ci && npm run tauri:dev'
```
Vite auf Port 1420 + Tauri-Window. Rust wird unoptimiert gebaut, deutlich schneller für Iteration.

148
install.sh Normal file
View file

@ -0,0 +1,148 @@
#!/usr/bin/env bash
# Claude Desktop — One-Click-Installer für NixOS
#
# Installiert:
# - Nix-Wrapper-Paket (nix/default.nix) ins User-Profil via nix-env
# - Natives Binary aus der Forgejo Package Registry nach ~/.local/share/claude-desktop/bin
# - Desktop-Entry im System-Menue (KDE/GNOME/…)
#
# Aufruf:
# curl -fsSL https://git.data-it-solution.de/data/claude-desktop/raw/branch/main/install.sh | bash
#
# Oder lokal:
# ./install.sh
#
# Env-Vars (optional):
# CLAUDE_DESKTOP_TOKEN — Forgejo Read-Token (sonst wird einmalig gefragt)
# CLAUDE_DESKTOP_BRANCH — Git-Branch für Wrapper-Dateien (default: main)
set -euo pipefail
# --- Konfiguration ---------------------------------------------------------
REPO_URL="https://git.data-it-solution.de/data/claude-desktop"
PKG_BASE="https://git.data-it-solution.de/api/packages/data/generic/claude-desktop/latest"
APP_DIR="$HOME/.local/share/claude-desktop"
BIN_DIR="$APP_DIR/bin"
TOKEN_FILE="$HOME/.config/claude-desktop/token"
BRANCH="${CLAUDE_DESKTOP_BRANCH:-main}"
# --- Farben ----------------------------------------------------------------
if [ -t 1 ]; then
BOLD="$(tput bold)"; RED="$(tput setaf 1)"; GRN="$(tput setaf 2)"
YEL="$(tput setaf 3)"; BLU="$(tput setaf 4)"; RST="$(tput sgr0)"
else
BOLD=""; RED=""; GRN=""; YEL=""; BLU=""; RST=""
fi
step() { echo "${BOLD}${BLU}${RST} $*"; }
ok() { echo " ${GRN}${RST} $*"; }
err() { echo "${RED}${RST} $*" >&2; exit 1; }
# --- 1. Voraussetzungen pruefen -------------------------------------------
step "Voraussetzungen pruefen"
for cmd in nix-build nix-env curl jq sha256sum; do
command -v "$cmd" >/dev/null || err "$cmd nicht installiert. Abbruch."
done
ok "Nix, curl, jq, sha256sum vorhanden"
if [ ! -f /etc/NIXOS ] && [ ! -e /etc/nix/nix.conf ]; then
echo " ${YEL}!${RST} Kein NixOS-System erkannt — der Installer setzt Nix voraus."
fi
# --- 2. Forgejo-Token besorgen --------------------------------------------
step "Forgejo-Token laden"
TOKEN="${CLAUDE_DESKTOP_TOKEN:-}"
if [ -z "$TOKEN" ] && [ -f "$TOKEN_FILE" ]; then
TOKEN=$(cat "$TOKEN_FILE")
ok "Token aus $TOKEN_FILE geladen"
fi
if [ -z "$TOKEN" ]; then
echo " Forgejo Read-Token für Package Registry (Basic-Auth User: ${BOLD}data${RST}):"
read -rs -p " Token: " TOKEN
echo
mkdir -p "$(dirname "$TOKEN_FILE")"
printf '%s' "$TOKEN" > "$TOKEN_FILE"
chmod 600 "$TOKEN_FILE"
ok "Token in $TOKEN_FILE gespeichert (chmod 600)"
fi
# --- 3. Manifest laden ----------------------------------------------------
step "Manifest abrufen (latest/update.json)"
MANIFEST=$(curl -fsSL --user "data:$TOKEN" "$PKG_BASE/update.json") \
|| err "Manifest nicht erreichbar (Token falsch? Netz?)"
VERSION=$(echo "$MANIFEST" | jq -r '.version')
BINARY_NAME=$(echo "$MANIFEST" | jq -r '.binary_filename // empty')
BINARY_SHA=$(echo "$MANIFEST" | jq -r '.binary_sha256 // empty')
if [ -z "$BINARY_NAME" ] || [ -z "$BINARY_SHA" ]; then
err "update.json enthaelt kein binary_filename/binary_sha256. Alter CI-Build? Erst neue Pipeline laufen lassen."
fi
ok "Version $VERSION, Binary $BINARY_NAME"
# --- 4. Binary herunterladen ----------------------------------------------
step "Binary laden -> $BIN_DIR"
mkdir -p "$BIN_DIR"
TMP_BIN=$(mktemp)
trap 'rm -f "$TMP_BIN"' EXIT
curl -fsSL --user "data:$TOKEN" -o "$TMP_BIN" "$PKG_BASE/$BINARY_NAME" \
|| err "Binary-Download fehlgeschlagen"
ACTUAL_SHA=$(sha256sum "$TMP_BIN" | awk '{print $1}')
if [ "$ACTUAL_SHA" != "$BINARY_SHA" ]; then
err "SHA256-Mismatch: erwartet $BINARY_SHA, bekommen $ACTUAL_SHA"
fi
ok "SHA256 verifiziert"
mv "$TMP_BIN" "$BIN_DIR/claude-desktop"
chmod +x "$BIN_DIR/claude-desktop"
ok "Binary installiert: $BIN_DIR/claude-desktop"
# --- 5. Nix-Wrapper bauen -------------------------------------------------
step "Nix-Wrapper-Paket bauen"
WORKDIR=$(mktemp -d)
trap 'rm -f "$TMP_BIN"; rm -rf "$WORKDIR"' EXIT
mkdir -p "$WORKDIR/nix" "$WORKDIR/src-tauri/icons"
for f in \
"nix/default.nix" \
"nix/claude-desktop.desktop" \
"src-tauri/icons/icon.png"; do
curl -fsSL -o "$WORKDIR/$f" "$REPO_URL/raw/branch/$BRANCH/$f" \
|| err "Kann $f nicht laden ($REPO_URL/raw/branch/$BRANCH/$f)"
done
ok "Wrapper-Quelldateien geladen"
pushd "$WORKDIR" >/dev/null
RESULT=$(nix-build nix/default.nix --no-out-link 2>&1 | tail -1)
popd >/dev/null
[ -d "$RESULT" ] || err "nix-build fehlgeschlagen"
ok "Paket gebaut: $RESULT"
# --- 6. Ins User-Profil installieren --------------------------------------
step "Ins User-Profil installieren (nix-env)"
nix-env --uninstall claude-desktop 2>/dev/null || true
nix-env -i "$RESULT"
ok "claude-desktop im \$PATH verfuegbar"
# --- 7. Desktop-Datenbank refreshen ---------------------------------------
step "Desktop-Menue aktualisieren"
if command -v update-desktop-database >/dev/null; then
update-desktop-database "$HOME/.nix-profile/share/applications" 2>/dev/null || true
fi
if command -v gtk-update-icon-cache >/dev/null; then
gtk-update-icon-cache -q "$HOME/.nix-profile/share/icons/hicolor" 2>/dev/null || true
fi
ok "Menue + Icon-Cache aktualisiert"
# --- Fertig ---------------------------------------------------------------
echo
echo "${BOLD}${GRN}✓ Claude Desktop v${VERSION} installiert${RST}"
echo
echo "Starten:"
echo " ${BOLD}claude-desktop${RST} (Terminal)"
echo " oder aus dem System-Menue: 'Claude Desktop'"
echo
echo "Updates holt die App selbstaendig (Button in Settings oder Auto-Check beim Start)."

94
nix/INSTALL.md Normal file
View file

@ -0,0 +1,94 @@
# Claude Desktop — NixOS-Integration
Dauerhaftes Einbinden der App ins System **und** funktionierendes Auto-Update. Der Trick: das Nix-Paket liefert nur einen Wrapper + Desktop-Entry, das eigentliche Binary lebt im User-Home (`~/.local/share/claude-desktop/bin/`), damit der Auto-Updater es ersetzen kann — der Nix-Store ist read-only.
## Einmaliger Setup
### 1. Binary einmal nativ bauen
```bash
cd <repo>
CARGO_TARGET_DIR=/tmp/claude-target \
nix-shell shell.nix --run 'npm ci && npm run tauri build -- --bundles appimage'
```
Das liefert das Binary unter `/tmp/claude-target/release/claude-desktop` (~13 MB). Das Bundling-Fail am Ende ignorieren — das Binary ist fertig.
### 2. Nix-Paket in die System-Config einbinden
**Variante A — configuration.nix (system-weit):**
```nix
{ pkgs, ... }:
{
environment.systemPackages = [
(import /pfad/zum/claude-desktop/nix/default.nix { inherit pkgs; })
];
}
```
Dann `sudo nixos-rebuild switch`.
**Variante B — home-manager (nur eigener User):**
```nix
{ pkgs, ... }:
{
home.packages = [
(import /pfad/zum/claude-desktop/nix/default.nix { inherit pkgs; })
];
}
```
Dann `home-manager switch`.
**Variante C — nur testen ohne System-Config:**
```bash
nix-build nix/default.nix
nix-env -i ./result
```
### 3. Binary in den User-Home kopieren
```bash
claude-desktop-install
```
Macht `cp /tmp/claude-target/release/claude-desktop ~/.local/share/claude-desktop/bin/`. Das Paket liefert diesen Helper mit.
### 4. Starten
- Aus dem KDE/GNOME-Menü: „Claude Desktop"
- Oder im Terminal: `claude-desktop`
## Wie der Auto-Update in diesem Setup funktioniert
- Der Launcher setzt `CLAUDE_DESKTOP_NIX_WRAPPER=1` und `CLAUDE_DESKTOP_BIN=$HOME/.local/share/claude-desktop/bin/claude-desktop` als Env-Vars
- `apply_update` in der App erkennt diesen Modus und überschreibt das User-Binary statt `$APPIMAGE`
- Der Rename-Trick klappt, weil `~/.local/share` schreibbar ist
- Nach Update-Apply macht Tauri `app.restart()` — der Nix-Wrapper startet das neue Binary mit denselben LD-Libs
## Bekannte Einschränkungen (v1)
- **Auto-Update lädt aktuell nur das AppImage** aus der Package Registry, das im Nix-Modus nicht direkt läuft. Kurzfristig wird die heruntergeladene Datei ins User-Binary-Ziel geschrieben → beim Neustart schlägt sie fehl. **Workaround:** bei Update-Hinweis manuell neu bauen: `git pull; npm run tauri build; claude-desktop-install`.
- **Saubere Lösung (geplant):** CI lädt zusätzlich ein natives Binary in die Package Registry (extract aus dem AppImage-squashfs), `update.rs` nimmt je nach Modus AppImage oder Binary.
## Deinstallation
```bash
# Aus System-Config entfernen (configuration.nix/home.nix bearbeiten, dann switch)
# User-Binary + Daten loeschen:
rm -rf ~/.local/share/claude-desktop
rm -rf ~/.local/share/de.alles-watt-laeuft.claude-desktop
```
## Debugging
```bash
# Sehen was der Launcher macht
bash -x $(which claude-desktop)
# Pruefen ob das User-Binary existiert & ausfuehrbar ist
ls -l ~/.local/share/claude-desktop/bin/claude-desktop
# Library-Pfad der wrapped-Version inspizieren
cat $(which claude-desktop)
```

View file

@ -0,0 +1,13 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=Claude Desktop
GenericName=KI-Assistent
Comment=Native Desktop-App für Claude Code
Exec=claude-desktop %U
Icon=claude-desktop
Terminal=false
Categories=Development;IDE;Utility;
Keywords=claude;ai;code;assistant;anthropic;
StartupWMClass=claude-desktop
StartupNotify=true

129
nix/default.nix Normal file
View file

@ -0,0 +1,129 @@
# Claude Desktop — Nix-Wrapper-Paket
#
# Liefert:
# $out/bin/claude-desktop — Launcher mit LD_LIBRARY_PATH
# $out/bin/claude-desktop-install — Installer (kopiert Binary nach ~/.local/share)
# $out/share/applications/… — Desktop-Entry
# $out/share/icons/… — Icon
#
# Das eigentliche Binary lebt unter ~/.local/share/claude-desktop/bin/claude-desktop
# (writable), damit der Auto-Updater es ersetzen kann. Nix-Store ist read-only und
# waere deshalb inkompatibel mit dem Rename-Trick in apply_update().
#
# Einbinden in /etc/nixos/configuration.nix:
#
# environment.systemPackages = [
# (import /pfad/zum/claude-desktop/nix/default.nix { inherit pkgs; })
# ];
#
# Oder per home-manager:
# home.packages = [ (import ./claude-desktop/nix/default.nix { inherit pkgs; }) ];
{ pkgs ? import <nixpkgs> {} }:
let
# Alle Laufzeit-Libs, die das Tauri-Binary braucht (parallel zu shell.nix).
runtimeLibs = with pkgs; [
webkitgtk_4_1
libappindicator-gtk3
librsvg
gtk3
glib
cairo
pango
gdk-pixbuf
libsoup_3
at-spi2-atk
openssl
];
in
pkgs.stdenv.mkDerivation {
pname = "claude-desktop";
version = "0.1.0";
# Keine Quelldateien — Wir packen nur Wrapper + Desktop-Entry
dontUnpack = true;
nativeBuildInputs = [ pkgs.makeWrapper ];
installPhase = ''
runHook preInstall
mkdir -p $out/bin $out/share/applications $out/share/icons/hicolor/256x256/apps
# 1) Launcher: startet das User-Binary mit Nix-LD_LIBRARY_PATH
cat > $out/bin/claude-desktop <<'LAUNCHER'
#!${pkgs.bash}/bin/bash
# Claude Desktop — NixOS-Launcher
set -e
APP_DIR="$HOME/.local/share/claude-desktop"
BIN="$APP_DIR/bin/claude-desktop"
if [ ! -x "$BIN" ]; then
echo " Claude-Desktop-Binary nicht gefunden: $BIN" >&2
echo "" >&2
echo "Erst installieren (aus fertigem Build in /tmp/claude-target):" >&2
echo " claude-desktop-install" >&2
echo "" >&2
echo "Oder neu bauen im Repo:" >&2
echo " CARGO_TARGET_DIR=/tmp/claude-target \\" >&2
echo " nix-shell shell.nix --run 'npm ci && npm run tauri build'" >&2
echo " claude-desktop-install" >&2
exit 1
fi
# Marker fuer update.rs: wir laufen unter Nix-Wrapper
export CLAUDE_DESKTOP_NIX_WRAPPER=1
export CLAUDE_DESKTOP_BIN="$BIN"
exec "$BIN" "$@"
LAUNCHER
chmod +x $out/bin/claude-desktop
# LD_LIBRARY_PATH dauerhaft ans Launcher-Script binden
wrapProgram $out/bin/claude-desktop \
--prefix LD_LIBRARY_PATH : ${pkgs.lib.makeLibraryPath runtimeLibs}
# 2) Installer: kopiert ein frisch gebautes Binary an den Ziel-Ort
cat > $out/bin/claude-desktop-install <<'INSTALLER'
#!${pkgs.bash}/bin/bash
set -e
SRC="''${1:-/tmp/claude-target/release/claude-desktop}"
DEST_DIR="$HOME/.local/share/claude-desktop/bin"
DEST="$DEST_DIR/claude-desktop"
if [ ! -x "$SRC" ]; then
echo " Quelle nicht gefunden oder nicht ausfuehrbar: $SRC" >&2
echo "" >&2
echo "Erst bauen:" >&2
echo " cd <repo>; CARGO_TARGET_DIR=/tmp/claude-target \\" >&2
echo " nix-shell shell.nix --run 'npm ci && npm run tauri build'" >&2
exit 1
fi
mkdir -p "$DEST_DIR"
cp "$SRC" "$DEST"
chmod +x "$DEST"
echo " Claude Desktop installiert nach $DEST"
echo " Starten mit: claude-desktop (oder aus KDE-Menue)"
INSTALLER
chmod +x $out/bin/claude-desktop-install
# 3) Desktop-Entry
cp ${./claude-desktop.desktop} $out/share/applications/claude-desktop.desktop
# 4) Icon
cp ${../src-tauri/icons/icon.png} $out/share/icons/hicolor/256x256/apps/claude-desktop.png
runHook postInstall
'';
meta = with pkgs.lib; {
description = "Native Desktop-App fuer Claude Code (Wrapper-Paket, Binary in ~/.local/share)";
homepage = "https://git.data-it-solution.de/data/claude-desktop";
license = licenses.mit;
platforms = platforms.linux;
mainProgram = "claude-desktop";
};
}

1
src-tauri/Cargo.lock generated
View file

@ -487,6 +487,7 @@ dependencies = [
"rusqlite",
"serde",
"serde_json",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-shell",

View file

@ -26,6 +26,7 @@ reqwest = { version = "0.12", features = ["json", "multipart", "stream"] }
base64 = "0.22"
tokio-tungstenite = "0.23"
futures-util = "0.3"
sha2 = "0.10"
[profile.release]
panic = "abort"

View file

@ -1,43 +1,58 @@
// Claude Desktop — Auto-Update System
// Prüft Forgejo-Releases und aktualisiert das AppImage
// 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, Manager, Emitter};
use tauri::{AppHandle, Emitter, Manager};
/// Forgejo API Konfiguration
const FORGEJO_URL: &str = "https://git.data-it-solution.de";
const REPO_OWNER: &str = "data";
const REPO_NAME: &str = "claude-desktop";
/// Endpoint der Manifest-Datei
const UPDATE_JSON_URL: &str =
"https://git.data-it-solution.de/api/packages/data/generic/claude-desktop/latest/update.json";
/// Aktuelle App-Version (aus Cargo.toml)
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
/// 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/";
/// Release-Info von Forgejo
/// 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 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>,
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>,
}
/// 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,
/// 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 Frontend
/// Update-Status für das Frontend
#[derive(Debug, Clone, Serialize)]
pub struct UpdateStatus {
pub available: bool,
@ -46,69 +61,7 @@ pub struct UpdateStatus {
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),
})
pub sha256: Option<String>,
}
/// Download-Fortschritt
@ -119,31 +72,136 @@ pub struct DownloadProgress {
pub percent: f32,
}
/// Lädt das Update herunter
/// 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 = client
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);
// Temp-Verzeichnis für Download
let cache_dir = app.path().app_cache_dir()
// 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();
let file_name = download_url.split('/').last().unwrap_or("update.AppImage");
// Sanitize: Nur Basename, keine Pfad-Traversal (z.B. "../böse.sh")
let file_name = std::path::Path::new(file_name)
// 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");
@ -152,6 +210,7 @@ pub async fn download_update(
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();
@ -160,10 +219,9 @@ pub async fn download_update(
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;
// Fortschritt an Frontend senden
let progress = DownloadProgress {
downloaded,
total: total_size,
@ -176,6 +234,19 @@ pub async fn download_update(
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)]
{
@ -188,73 +259,76 @@ pub async fn download_update(
.map_err(|e| format!("Permissions-Fehler: {}", e))?;
}
println!("✅ Update heruntergeladen: {:?}", download_path);
println!("✅ Update heruntergeladen + verifiziert: {:?}", download_path);
Ok(download_path)
}
/// Wendet das Update an (ersetzt AppImage und startet neu)
/// 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
#[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();
// 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
};
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)
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()
})?;
// Backup-Pfad: gleiches Verzeichnis, Suffix .backup
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 ({}): {}", mode_label, e)
})?;
// Backup löschen
std::fs::remove_file(&backup_path).ok();
println!("✅ Update installiert, starte neu...");
// App neustarten (kehrt nicht zurück)
println!("✅ Update installiert ({}), starte neu...", mode_label);
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
/// Liefert die aktuelle Version (für UI-Anzeige)
#[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 {
/// 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;
}
}
false
candidate > current
}
#[cfg(test)]
@ -262,12 +336,17 @@ 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"));
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"));
}
}

View file

@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { currentModel, agentMode, type AgentMode } from '$lib/stores/app';
import { updateCheckManual } from '$lib/stores/updateTrigger';
interface ModelInfo {
id: string;
@ -13,6 +14,7 @@
let selectedModel = '';
let loading = true;
let saving = false;
let appVersion = '';
// Modell-Icons
const modelIcons: Record<string, string> = {
@ -73,12 +75,19 @@
// Agent-Modus laden
const currentMode: string = await invoke('get_agent_mode');
$agentMode = currentMode as AgentMode;
// Version laden
appVersion = await invoke('get_current_version');
} catch (err) {
console.error('Fehler beim Laden:', err);
}
loading = false;
}
function triggerUpdateCheck() {
updateCheckManual.set(true);
}
async function changeMode(modeId: AgentMode) {
if (modeId === $agentMode) return;
@ -209,6 +218,25 @@
<span class="setting-value placeholder">Wird aus Session geladen</span>
</div>
</section>
<section class="settings-section">
<h3>🔄 Version & Updates</h3>
<div class="setting-row">
<span class="setting-label">Aktuelle Version</span>
<span class="setting-value">{appVersion || '—'}</span>
</div>
<div class="update-actions">
<button class="update-btn" on:click={triggerUpdateCheck}>
Nach Updates suchen
</button>
{#if appVersion === 'dev'}
<p class="dev-hint">
Entwicklungs-Build — Auto-Update ist deaktiviert.
Nur Pipeline-Builds können aktualisiert werden.
</p>
{/if}
</div>
</section>
</div>
{/if}
</div>
@ -437,4 +465,40 @@
.mode-info strong {
color: var(--accent);
}
/* Update-Sektion */
.update-actions {
margin-top: var(--spacing-sm);
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.update-btn {
align-self: flex-start;
padding: var(--spacing-sm) var(--spacing-md);
background: var(--accent);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.update-btn:hover {
background: var(--accent-hover);
}
.dev-hint {
margin: 0;
padding: var(--spacing-sm);
font-size: 0.75rem;
color: var(--text-secondary);
background: rgba(234, 179, 8, 0.08);
border: 1px solid rgba(234, 179, 8, 0.2);
border-radius: var(--radius-sm);
line-height: 1.4;
}
</style>

View file

@ -2,6 +2,7 @@
import { onMount, onDestroy } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { updateDialogOpen, updateCheckManual } from '$lib/stores/updateTrigger';
interface UpdateStatus {
available: boolean;
@ -10,6 +11,7 @@
release_notes: string | null;
download_url: string | null;
download_size: number | null;
sha256: string | null;
}
interface DownloadProgress {
@ -18,39 +20,72 @@
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 checking = false;
let manualMode = false;
let progressListener: UnlistenFn | null = null;
let manualUnsub: (() => void) | null = null;
// Reaktiv: wenn Store schließt → State zurücksetzen
$: if (!$updateDialogOpen) {
resetState();
}
// Manueller Check-Trigger aus dem Settings-Panel
$: manualMode = $updateCheckManual;
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;
});
// Manueller Check wird via Store gestartet
manualUnsub = updateCheckManual.subscribe((active) => {
if (active) {
// Store-Reset, bevor wir starten — damit erneutes Klicken wieder triggert
updateCheckManual.set(false);
runCheck(true);
}
});
// Auto-Check 3s nach Start (nur wenn Dialog nicht bereits offen)
setTimeout(() => runCheck(false), 3000);
});
onDestroy(() => {
progressListener?.();
manualUnsub?.();
});
async function checkForUpdate() {
async function runCheck(manual: boolean) {
checking = true;
error = null;
try {
const status: UpdateStatus = await invoke('check_for_update');
if (status.available) {
updateInfo = status;
showDialog = true;
console.log('🔄 Update verfügbar:', status.latest_version);
if (status.available) {
manualMode = manual;
updateDialogOpen.set(true);
} else if (manual) {
// Bei manuellem Check auch "kein Update"-Dialog zeigen
manualMode = true;
updateDialogOpen.set(true);
}
} catch (err) {
console.debug('Update-Check fehlgeschlagen:', err);
if (manual) {
error = String(err);
updateDialogOpen.set(true);
} else {
console.debug('Auto-Update-Check fehlgeschlagen:', err);
}
} finally {
checking = false;
}
}
@ -59,11 +94,12 @@
downloading = true;
error = null;
progress = { downloaded: 0, total: updateInfo.download_size || 0, percent: 0 };
progress = { downloaded: 0, total: 0, percent: 0 };
try {
downloadedPath = await invoke('download_update', {
downloadUrl: updateInfo.download_url,
expectedSha256: updateInfo.sha256,
});
console.log('✅ Download abgeschlossen:', downloadedPath);
} catch (err) {
@ -74,21 +110,24 @@
async function applyUpdate() {
if (!downloadedPath) return;
try {
await invoke('apply_update', { updatePath: downloadedPath });
// App wird neugestartet, kein weiterer Code erreicht
// App startet neu, kein weiterer Code erreicht
} catch (err) {
error = String(err);
}
}
function closeDialog() {
showDialog = false;
updateDialogOpen.set(false);
}
function resetState() {
downloading = false;
progress = null;
error = null;
downloadedPath = null;
manualMode = false;
}
function formatSize(bytes: number): string {
@ -96,19 +135,33 @@
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
$: isNoUpdateDialog = $updateDialogOpen && updateInfo && !updateInfo.available && !error;
</script>
{#if showDialog && updateInfo}
{#if $updateDialogOpen && 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">
{#if isNoUpdateDialog}
<h2>✅ Aktuell</h2>
{:else if error && !updateInfo.available}
<h2>⚠️ Update-Check fehlgeschlagen</h2>
{:else}
<h2>🔄 Update verfügbar</h2>
{/if}
<button class="close-btn" on:click={closeDialog}>✕</button>
</div>
<div class="modal-body">
{#if isNoUpdateDialog}
<p class="no-update-text">
Du verwendest bereits die neueste Version:
<strong>v{updateInfo.current_version}</strong>
</p>
{:else if updateInfo.available}
<div class="version-info">
<span class="current">v{updateInfo.current_version}</span>
<span class="arrow"></span>
@ -124,23 +177,22 @@
</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">
{#if progress.total > 0}
{formatSize(progress.downloaded)} / {formatSize(progress.total)}
({progress.percent.toFixed(0)}%)
{:else}
{formatSize(progress.downloaded)} geladen...
{/if}
</span>
</div>
{/if}
{/if}
{#if error}
<div class="error">
@ -150,7 +202,9 @@
</div>
<div class="modal-footer">
{#if downloadedPath}
{#if isNoUpdateDialog}
<button class="btn btn-primary" on:click={closeDialog}>OK</button>
{:else if downloadedPath}
<button class="btn btn-primary" on:click={applyUpdate}>
Jetzt installieren & neustarten
</button>
@ -158,13 +212,13 @@
<button class="btn btn-disabled" disabled>
Wird heruntergeladen...
</button>
{:else}
<button class="btn btn-secondary" on:click={closeDialog}>
Später
</button>
{:else if updateInfo.available}
<button class="btn btn-secondary" on:click={closeDialog}>Später</button>
<button class="btn btn-primary" on:click={startDownload}>
Jetzt aktualisieren
</button>
{:else}
<button class="btn btn-primary" on:click={closeDialog}>Schließen</button>
{/if}
</div>
</div>
@ -243,6 +297,12 @@
font-weight: 600;
}
.no-update-text {
text-align: center;
font-size: 0.95rem;
margin: var(--spacing-md) 0;
}
.release-notes {
background: var(--bg-secondary);
border-radius: var(--radius-md);
@ -263,12 +323,6 @@
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;
}

View file

@ -1,3 +1,4 @@
// Stores re-export
export * from './app';
export * from './events';
export * from './updateTrigger';

View file

@ -0,0 +1,8 @@
import { writable } from 'svelte/store';
// Steuert Sichtbarkeit des UpdateDialog
export const updateDialogOpen = writable(false);
// Setzt das Dialog in den "manueller Check"-Modus:
// Ergebnis wird immer angezeigt — auch wenn kein Update vorliegt.
export const updateCheckManual = writable(false);