diff --git a/.forgejo/workflows/build-appimage.yml b/.forgejo/workflows/build-appimage.yml index 318d100..2643890 100644 --- a/.forgejo/workflows/build-appimage.yml +++ b/.forgejo/workflows/build-appimage.yml @@ -121,18 +121,29 @@ jobs: 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..." + # --- Scripts-Bundle fuer Nix-Wrapper vorbereiten --- + # Enthaelt claude-bridge.js, package.json und package-lock.json. + # Der Client fuehrt nach dem Extract `npm ci --omit=dev` aus → node_modules. + BUNDLE_NAME="claude-desktop-bundle_${APP_VERSION}.tar.gz" + tar -czf "/tmp/${BUNDLE_NAME}" \ + --transform='s,^,,' \ + scripts/claude-bridge.js \ + package.json \ + package-lock.json + + echo "Lade $FILENAME + $BINARY_NAME + $BUNDLE_NAME (v${APP_VERSION}) in Package Registry..." BASE="https://git.data-it-solution.de/api/packages/data/generic/claude-desktop" # SHA256-Hashes fuer Integritaets-Check in update.json SHA256=$(sha256sum "$APPIMAGE" | awk '{print $1}') BINARY_SHA256=$(sha256sum "/tmp/${BINARY_NAME}" | awk '{print $1}') + BUNDLE_SHA256=$(sha256sum "/tmp/${BUNDLE_NAME}" | awk '{print $1}') NOTES=$(git log -1 --pretty=%s) RELEASED_AT=$(date -Iseconds) # update.json-Manifest bauen — wird vom Client-Updater (update.rs) gelesen. - # binary_* werden im Nix-Wrapper-Modus verwendet (siehe nix/default.nix). + # binary_*/bundle_* werden im Nix-Wrapper-Modus verwendet (siehe nix/default.nix). cat > /tmp/update.json </dev/null; then fi fi +# npm (fuer Bridge-Dependencies) — analog zu jq aus nixpkgs wenn nicht vorhanden +if ! command -v npm >/dev/null; then + echo " ${YEL}!${RST} npm fehlt — hole nodejs aus nixpkgs" + NODE_OUT=$(nix-build '' -A nodejs_22 --no-out-link 2>/dev/null || true) + if [ -n "$NODE_OUT" ] && [ -x "$NODE_OUT/bin/npm" ]; then + export PATH="$NODE_OUT/bin:$PATH" + ok "nodejs temporaer aus $NODE_OUT" + else + err "nodejs konnte nicht ueber nix-build bezogen werden. Abbruch." + fi +fi + if [ ! -f /etc/NIXOS ] && [ ! -e /etc/nix/nix.conf ]; then echo " ${YEL}!${RST} Kein NixOS-System erkannt — der Installer setzt Nix voraus." fi @@ -86,12 +98,17 @@ MANIFEST=$(curl -fsSL --user "data:$TOKEN" "$PKG_BASE/update.json") \ VERSION=$(echo "$MANIFEST" | jq -r '.version') BINARY_NAME=$(echo "$MANIFEST" | jq -r '.binary_filename // empty') BINARY_SHA=$(echo "$MANIFEST" | jq -r '.binary_sha256 // empty') +BUNDLE_NAME=$(echo "$MANIFEST" | jq -r '.bundle_filename // empty') +BUNDLE_SHA=$(echo "$MANIFEST" | jq -r '.bundle_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 +if [ -z "$BUNDLE_NAME" ] || [ -z "$BUNDLE_SHA" ]; then + err "update.json enthaelt kein bundle_filename/bundle_sha256 (scripts+node_modules). Alter CI-Build? Erst neue Pipeline laufen lassen." +fi -ok "Version $VERSION, Binary $BINARY_NAME" +ok "Version $VERSION, Binary $BINARY_NAME, Bundle $BUNDLE_NAME" # --- 4. Binary herunterladen ---------------------------------------------- step "Binary laden -> $BIN_DIR" @@ -112,6 +129,30 @@ mv "$TMP_BIN" "$BIN_DIR/claude-desktop" chmod +x "$BIN_DIR/claude-desktop" ok "Binary installiert: $BIN_DIR/claude-desktop" +# --- 4b. Scripts-Bundle laden + entpacken + npm ci ------------------------ +step "Scripts-Bundle laden + entpacken -> $APP_DIR" +# node+npm brauchen wir spaetestens hier +if ! command -v npm >/dev/null; then + err "npm nicht gefunden. Installiere nodejs im System oder in der Nix-Shell." +fi +TMP_BUNDLE=$(mktemp --suffix=.tar.gz) +trap 'rm -f "$TMP_BIN" "$TMP_BUNDLE"' EXIT +curl -fsSL --user "data:$TOKEN" -o "$TMP_BUNDLE" "$PKG_BASE/$BUNDLE_NAME" \ + || err "Bundle-Download fehlgeschlagen" +ACTUAL_BUNDLE_SHA=$(sha256sum "$TMP_BUNDLE" | awk '{print $1}') +if [ "$ACTUAL_BUNDLE_SHA" != "$BUNDLE_SHA" ]; then + err "Bundle-SHA256-Mismatch: erwartet $BUNDLE_SHA, bekommen $ACTUAL_BUNDLE_SHA" +fi +ok "Bundle-SHA256 verifiziert" + +tar -xzf "$TMP_BUNDLE" -C "$APP_DIR" +ok "Bundle entpackt" + +step "npm ci --omit=dev im User-Home (dauert 30-60 s)" +(cd "$APP_DIR" && npm ci --omit=dev --no-audit --no-fund) \ + || err "npm ci schlug fehl — Bridge-Abhaengigkeiten nicht installiert" +ok "node_modules (production) installiert" + # --- 5. Nix-Wrapper bauen ------------------------------------------------- step "Nix-Wrapper-Paket bauen" WORKDIR=$(mktemp -d) diff --git a/nix/default.nix b/nix/default.nix index 7bb413d..a6b1b4c 100644 --- a/nix/default.nix +++ b/nix/default.nix @@ -36,6 +36,10 @@ let at-spi2-atk openssl ]; + + # Laufzeit-Binaries die die App spawnen muss (node fuer claude-bridge.js, + # npm fuer apply_bundle_update, tar fuer Bundle-Extract) + runtimeBins = [ pkgs.nodejs_22 pkgs.gnutar pkgs.gzip ]; in pkgs.stdenv.mkDerivation { pname = "claude-desktop"; @@ -80,9 +84,11 @@ pkgs.stdenv.mkDerivation { LAUNCHER chmod +x $out/bin/claude-desktop - # LD_LIBRARY_PATH dauerhaft ans Launcher-Script binden + # LD_LIBRARY_PATH + PATH dauerhaft ans Launcher-Script binden + # PATH: node/npm/tar muessen fuer die Bridge und apply_bundle_update verfuegbar sein wrapProgram $out/bin/claude-desktop \ - --prefix LD_LIBRARY_PATH : ${pkgs.lib.makeLibraryPath runtimeLibs} + --prefix LD_LIBRARY_PATH : ${pkgs.lib.makeLibraryPath runtimeLibs} \ + --prefix PATH : ${pkgs.lib.makeBinPath runtimeBins} # 2) Installer: kopiert ein frisch gebautes Binary an den Ziel-Ort cat > $out/bin/claude-desktop-install <<'INSTALLER' diff --git a/src-tauri/src/claude.rs b/src-tauri/src/claude.rs index ff24fcb..ab27c48 100644 --- a/src-tauri/src/claude.rs +++ b/src-tauri/src/claude.rs @@ -81,12 +81,16 @@ pub fn start_bridge(app: &AppHandle) -> Result<(), String> { .ok_or("Kein Parent-Verzeichnis")? .to_path_buf(); - // Script in mehreren Pfaden suchen + // Script in mehreren Pfaden suchen — Reihenfolge wichtig! + // 1. bin/../scripts/ → Nix-Wrapper-Layout (~/.local/share/claude-desktop/scripts/) + // 2. bin/scripts/ → Bundle-neben-Binary (AppImage extrahiert / alte Konvention) + // 3. Cargo-Manifest → Entwicklungs-Build direkt aus dem Repo + // 4. CWD/scripts/ → Fallback falls aus Projektverzeichnis gestartet + let parent_dir = exe_dir.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| exe_dir.clone()); let candidates = vec![ + parent_dir.join("scripts").join("claude-bridge.js"), exe_dir.join("scripts").join("claude-bridge.js"), - // Entwicklung: relativ zum Cargo-Manifest std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../scripts/claude-bridge.js"), - // Fallback: CWD std::env::current_dir().unwrap_or_default().join("scripts/claude-bridge.js"), ]; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5b5e496..f9010a8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -136,6 +136,7 @@ pub fn run() { update::check_for_update, update::download_update, update::apply_update, + update::apply_bundle_update, update::get_current_version, // Slash-Command Registry commands::get_slash_commands, diff --git a/src-tauri/src/update.rs b/src-tauri/src/update.rs index 9004c3c..8594cc0 100644 --- a/src-tauri/src/update.rs +++ b/src-tauri/src/update.rs @@ -117,9 +117,11 @@ const UPDATE_TOKEN: Option<&str> = option_env!("UPDATE_TOKEN"); 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). +/// `filename`/`sha256` → AppImage (Standard-Installation, Scripts + node_modules sind darin enthalten). +/// `binary_filename`/`binary_sha256` → pures Binary fuer Nix-Wrapper-Installation. +/// `bundle_filename`/`bundle_sha256` → tar.gz mit scripts/ + package.json + package-lock.json +/// fuer Nix-Wrapper (npm ci --omit=dev laeuft beim Install/Update). +/// Aeltere Manifest-Versionen ohne diese Felder werden toleriert (Option). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateManifest { pub version: String, @@ -129,6 +131,10 @@ pub struct UpdateManifest { pub binary_filename: Option, #[serde(default)] pub binary_sha256: Option, + #[serde(default)] + pub bundle_filename: Option, + #[serde(default)] + pub bundle_sha256: Option, pub notes: Option, pub released_at: Option, } @@ -148,6 +154,9 @@ pub struct UpdateStatus { pub download_url: Option, pub download_size: Option, pub sha256: Option, + /// Bundle-URL (scripts + package.json tar.gz) — nur gesetzt im Nix-Wrapper-Modus + pub bundle_url: Option, + pub bundle_sha256: Option, } /// Download-Fortschritt @@ -187,6 +196,8 @@ pub async fn check_for_update() -> Result { download_url: None, download_size: None, sha256: None, + bundle_url: None, + bundle_sha256: None, }); } }; @@ -205,6 +216,8 @@ pub async fn check_for_update() -> Result { download_url: None, download_size: None, sha256: None, + bundle_url: None, + bundle_sha256: None, }); } @@ -232,6 +245,8 @@ pub async fn check_for_update() -> Result { download_url: None, download_size: None, sha256: None, + bundle_url: None, + bundle_sha256: None, }); } } @@ -241,6 +256,19 @@ pub async fn check_for_update() -> Result { let download_url = format!("{}{}", PACKAGE_BASE_URL, chosen_filename); + // Bundle (scripts + package.json tar.gz) nur im Nix-Wrapper-Modus relevant + let (bundle_url, bundle_sha256) = if is_nix_wrapper() { + match (&manifest.bundle_filename, &manifest.bundle_sha256) { + (Some(f), Some(h)) => ( + Some(format!("{}{}", PACKAGE_BASE_URL, f)), + Some(h.clone()), + ), + _ => (None, None), + } + } else { + (None, None) + }; + Ok(UpdateStatus { available, current_version: CURRENT_VERSION.to_string(), @@ -250,6 +278,8 @@ pub async fn check_for_update() -> Result { // Größe kennen wir erst beim Download (Content-Length) — hier None download_size: None, sha256: Some(chosen_sha256), + bundle_url, + bundle_sha256, }) } @@ -455,6 +485,110 @@ pub fn get_current_version() -> String { CURRENT_VERSION.to_string() } +/// Lädt das Scripts-Bundle (tar.gz mit scripts/ + package.json + package-lock.json) herunter, +/// verifiziert SHA256, entpackt nach ~/.local/share/claude-desktop/ und führt npm ci --omit=dev aus. +/// +/// Wird im Nix-Wrapper-Modus parallel zum Binary-Update benötigt, damit die claude-bridge.js +/// mit ihren node_modules neben dem Binary liegt (bin/../scripts/claude-bridge.js). +#[tauri::command] +pub async fn apply_bundle_update( + app: AppHandle, + bundle_url: String, + expected_sha256: Option, +) -> Result<(), String> { + use std::io::Write; + + // Nur im Nix-Wrapper-Modus relevant — im AppImage sind Scripts+node_modules eh drin + if !is_nix_wrapper() { + return Ok(()); + } + + let target_dir = std::env::var("HOME") + .map(|h| PathBuf::from(h).join(".local/share/claude-desktop")) + .map_err(|e| format!("$HOME nicht gesetzt: {}", e))?; + std::fs::create_dir_all(&target_dir) + .map_err(|e| format!("Ziel-Verzeichnis konnte nicht angelegt werden: {}", e))?; + + // === 1. Bundle herunterladen === + app.emit("bundle-progress", "download").ok(); + let client = reqwest::Client::new(); + let response = match UPDATE_TOKEN { + Some(token) => { + let creds = B64.encode(format!("{}:{}", UPDATE_USER, token)); + client + .get(&bundle_url) + .header("Authorization", format!("Basic {}", creds)) + } + None => client.get(&bundle_url), + } + .send() + .await + .map_err(|e| format!("Bundle-Download-Fehler: {}", e))?; + + if !response.status().is_success() { + return Err(format!("Bundle-HTTP-Fehler: {}", response.status())); + } + + let bundle_tmp = std::env::temp_dir().join(format!("claude-desktop-bundle-{}.tar.gz", std::process::id())); + let bytes = response + .bytes() + .await + .map_err(|e| format!("Bundle-Body-Fehler: {}", e))?; + + // SHA256 pruefen + if let Some(expected) = expected_sha256 { + let mut hasher = Sha256::new(); + hasher.update(&bytes); + let actual = format!("{:x}", hasher.finalize()); + if !actual.eq_ignore_ascii_case(&expected) { + return Err(format!( + "Bundle-SHA256-Mismatch: erwartet {}, bekommen {}", + expected, actual + )); + } + } + + std::fs::File::create(&bundle_tmp) + .and_then(|mut f| f.write_all(&bytes)) + .map_err(|e| format!("Bundle-Zwischendatei: {}", e))?; + + // === 2. Entpacken === + app.emit("bundle-progress", "extract").ok(); + let status = std::process::Command::new("tar") + .arg("-xzf") + .arg(&bundle_tmp) + .arg("-C") + .arg(&target_dir) + .status() + .map_err(|e| format!("tar konnte nicht gestartet werden: {}", e))?; + if !status.success() { + return Err(format!("tar-Extract schlug fehl (exit {})", status)); + } + std::fs::remove_file(&bundle_tmp).ok(); + + // === 3. npm ci --omit=dev === + app.emit("bundle-progress", "npm-install").ok(); + let output = std::process::Command::new("npm") + .arg("ci") + .arg("--omit=dev") + .current_dir(&target_dir) + .output() + .map_err(|e| format!("npm konnte nicht gestartet werden: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "npm ci --omit=dev schlug fehl (exit {}): {}", + output.status, + stderr.lines().take(10).collect::>().join("\n") + )); + } + + app.emit("bundle-progress", "done").ok(); + println!("✅ Bundle aktualisiert in {:?}", target_dir); + Ok(()) +} + /// Prüft ob eine Version ein gültiges Zeitstempel-Format hat (nur Ziffern und Bindestriche). /// Beispiel: "20260420-1300" → true, "dev" → false, "dev-local" → false fn is_timestamp_version(v: &str) -> bool { diff --git a/src/lib/components/UpdateDialog.svelte b/src/lib/components/UpdateDialog.svelte index 0058e92..dd13127 100644 --- a/src/lib/components/UpdateDialog.svelte +++ b/src/lib/components/UpdateDialog.svelte @@ -12,6 +12,8 @@ download_url: string | null; download_size: number | null; sha256: string | null; + bundle_url: string | null; + bundle_sha256: string | null; } interface DownloadProgress { @@ -138,6 +140,15 @@ awaitingConfirmation = false; preparing = true; try { + // Im Nix-Wrapper-Modus zuerst das scripts-Bundle aktualisieren (npm ci), + // damit nach dem Neustart die claude-bridge.js neben dem Binary liegt. + // apply_bundle_update ist no-op im AppImage-Modus. + if (updateInfo?.bundle_url) { + await invoke('apply_bundle_update', { + bundleUrl: updateInfo.bundle_url, + expectedSha256: updateInfo.bundle_sha256, + }); + } await invoke('apply_update', { updatePath: downloadedPath }); // App startet neu, kein weiterer Code erreicht } catch (err) {