diff --git a/.forgejo/workflows/build-appimage.yml b/.forgejo/workflows/build-appimage.yml index 9476de5..318d100 100644 --- a/.forgejo/workflows/build-appimage.yml +++ b/.forgejo/workflows/build-appimage.yml @@ -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 </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') diff --git a/.gitignore b/.gitignore index d6eb005..44b12bc 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,10 @@ dist/ src-tauri/target/ src-tauri/gen/ +# Nix build outputs +result +result-* + # IDE .vscode/ .idea/ diff --git a/BUILD-NIXOS.md b/BUILD-NIXOS.md new file mode 100644 index 0000000..76bafae --- /dev/null +++ b/BUILD-NIXOS.md @@ -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 (~10–60 s) +- Nur Frontend geändert → Vite+Cargo linken neu (~30 s) +- Abhängigkeiten neu → erster Cargo-Build dauert 5–15 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. diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..472880e --- /dev/null +++ b/install.sh @@ -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)." diff --git a/nix/INSTALL.md b/nix/INSTALL.md new file mode 100644 index 0000000..956d0f6 --- /dev/null +++ b/nix/INSTALL.md @@ -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 +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) +``` diff --git a/nix/claude-desktop.desktop b/nix/claude-desktop.desktop new file mode 100644 index 0000000..0707900 --- /dev/null +++ b/nix/claude-desktop.desktop @@ -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 diff --git a/nix/default.nix b/nix/default.nix new file mode 100644 index 0000000..7bb413d --- /dev/null +++ b/nix/default.nix @@ -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 {} }: + +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 ; 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"; + }; +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c74b58e..15a077c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -487,6 +487,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-shell", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index acea329..7c7f024 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/update.rs b/src-tauri/src/update.rs index af8d094..4fb3593 100644 --- a/src-tauri/src/update.rs +++ b/src-tauri/src/update.rs @@ -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, - pub draft: bool, - pub prerelease: bool, - pub created_at: String, - pub published_at: Option, - pub assets: Vec, +pub struct UpdateManifest { + pub version: String, + pub filename: String, + pub sha256: String, + #[serde(default)] + pub binary_filename: Option, + #[serde(default)] + pub binary_sha256: Option, + pub notes: Option, + pub released_at: Option, } -/// 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, pub download_url: Option, pub download_size: Option, -} - -/// Prüft ob ein Update verfügbar ist -#[tauri::command] -pub async fn check_for_update() -> Result { - 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 = 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, } /// 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 { + // 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, ) -> Result { 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 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); + 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 erstellen - let backup_path = appimage_path.with_extension("AppImage.backup"); - std::fs::rename(&appimage_path, &backup_path) - .map_err(|e| format!("Backup fehlgeschlagen: {}", e))?; + // 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 + }; - // 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) - })?; + std::fs::rename(&target, &backup_path) + .map_err(|e| format!("Backup fehlgeschlagen ({}): {}", mode_label, e))?; - // Backup löschen - std::fs::remove_file(&backup_path).ok(); + std::fs::rename(&update_path, &target).map_err(|e| { + // Rollback + std::fs::rename(&backup_path, &target).ok(); + format!("Update-Installation fehlgeschlagen ({}): {}", mode_label, e) + })?; - println!("✅ Update installiert, starte neu..."); + std::fs::remove_file(&backup_path).ok(); - // 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()) + println!("✅ Update installiert ({}), starte neu...", mode_label); + app.restart(); } -/// 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 { - 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; - } +/// 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")); } } diff --git a/src/lib/components/SettingsPanel.svelte b/src/lib/components/SettingsPanel.svelte index a52c06e..3307670 100644 --- a/src/lib/components/SettingsPanel.svelte +++ b/src/lib/components/SettingsPanel.svelte @@ -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 = { @@ -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 @@ Wird aus Session geladen + +
+

🔄 Version & Updates

+
+ Aktuelle Version + {appVersion || '—'} +
+
+ + {#if appVersion === 'dev'} +

+ Entwicklungs-Build — Auto-Update ist deaktiviert. + Nur Pipeline-Builds können aktualisiert werden. +

+ {/if} +
+
{/if} @@ -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; + } diff --git a/src/lib/components/UpdateDialog.svelte b/src/lib/components/UpdateDialog.svelte index 27831d8..c713a4b 100644 --- a/src/lib/components/UpdateDialog.svelte +++ b/src/lib/components/UpdateDialog.svelte @@ -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('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'); + updateInfo = status; if (status.available) { - updateInfo = status; - showDialog = true; - console.log('🔄 Update verfügbar:', status.latest_version); + 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,50 +135,63 @@ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } + + $: isNoUpdateDialog = $updateDialogOpen && updateInfo && !updateInfo.available && !error; -{#if showDialog && updateInfo} +{#if $updateDialogOpen && updateInfo}