[appimage] Bridge-Deploy: Scripts-Bundle + npm ci im Nix-Wrapper-Modus
All checks were successful
Build AppImage / build (push) Successful in 7m11s

Problem: Beim Nix-Wrapper lag nur das Binary unter ~/.local/share/claude-desktop/bin,
aber claude-bridge.js + node_modules waren nirgends deployt → "Bridge nicht gefunden"
beim ersten Chat-Versuch.

Loesung:
- claude.rs: Bridge-Such-Pfad um bin/../scripts erweitert (exe_dir.parent / scripts).
- update.rs: UpdateManifest + UpdateStatus um bundle_filename/bundle_sha256 erweitert.
  Neues Tauri-Command apply_bundle_update: laedt tar.gz, pruefte SHA256, entpackt
  nach ~/.local/share/claude-desktop, ruft npm ci --omit=dev auf. Im AppImage-Modus
  no-op (Bundle ist im AppImage enthalten).
- lib.rs: apply_bundle_update registriert.
- CI: packt scripts/claude-bridge.js + package.json + package-lock.json als
  claude-desktop-bundle_VERSION.tar.gz und laedt neben Binary in die Package Registry.
  update.json v3 enthaelt bundle_filename + bundle_sha256.
- install.sh: Erst-Installer laedt das Bundle, verifiziert SHA, entpackt, fuehrt
  npm ci --omit=dev aus. Holt nodejs bei Bedarf ueber nix-build (analog zu jq).
- UpdateDialog.svelte: ruft im Nix-Modus apply_bundle_update vor apply_update auf,
  damit nach dem Neustart Scripts + node_modules aktuell sind.
- nix/default.nix: nodejs_22 + tar + gzip im Wrapper-PATH, damit die App aus dem
  Binary heraus npm ci aufrufen kann.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-20 13:50:56 +02:00
parent 9be09897dd
commit d315f421ec
7 changed files with 235 additions and 12 deletions

View file

@ -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 <<EOF
{
"version": "${APP_VERSION}",
@ -140,6 +151,8 @@ jobs:
"sha256": "${SHA256}",
"binary_filename": "${BINARY_NAME}",
"binary_sha256": "${BINARY_SHA256}",
"bundle_filename": "${BUNDLE_NAME}",
"bundle_sha256": "${BUNDLE_SHA256}",
"notes": $(printf '%s' "$NOTES" | jq -Rs .),
"released_at": "${RELEASED_AT}"
}
@ -152,6 +165,7 @@ jobs:
for PATH_SUFFIX in \
"latest/${FILENAME}" "${APP_VERSION}/${FILENAME}" \
"latest/${BINARY_NAME}" "${APP_VERSION}/${BINARY_NAME}" \
"latest/${BUNDLE_NAME}" "${APP_VERSION}/${BUNDLE_NAME}" \
"latest/update.json" "${APP_VERSION}/update.json"; do
curl -sS -X DELETE \
--user "data:${{ secrets.REGISTRY_TOKEN }}" \
@ -180,6 +194,17 @@ jobs:
--upload-file "/tmp/${BINARY_NAME}" \
"${BASE}/latest/${BINARY_NAME}"
# Scripts-Bundle versioniert + latest hochladen
curl --fail -sS -X PUT \
--user "data:${{ secrets.REGISTRY_TOKEN }}" \
--upload-file "/tmp/${BUNDLE_NAME}" \
"${BASE}/${APP_VERSION}/${BUNDLE_NAME}"
curl --fail -sS -X PUT \
--user "data:${{ secrets.REGISTRY_TOKEN }}" \
--upload-file "/tmp/${BUNDLE_NAME}" \
"${BASE}/latest/${BUNDLE_NAME}"
# update.json versioniert + latest hochladen
curl --fail -sS -X PUT \
--user "data:${{ secrets.REGISTRY_TOKEN }}" \
@ -191,8 +216,9 @@ jobs:
--upload-file /tmp/update.json \
"${BASE}/latest/update.json"
echo "Upload abgeschlossen: ${FILENAME} + ${BINARY_NAME} (v${APP_VERSION})"
echo "Upload abgeschlossen: ${FILENAME} + ${BINARY_NAME} + ${BUNDLE_NAME} (v${APP_VERSION})"
echo " AppImage SHA256: ${SHA256}"
echo " Bundle SHA256: ${BUNDLE_SHA256}"
echo " Binary SHA256: ${BINARY_SHA256}"
- name: Upload to Release

View file

@ -57,6 +57,18 @@ if ! command -v jq >/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 '<nixpkgs>' -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)

View file

@ -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'

View file

@ -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"),
];

View file

@ -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,

View file

@ -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<String>,
#[serde(default)]
pub binary_sha256: Option<String>,
#[serde(default)]
pub bundle_filename: Option<String>,
#[serde(default)]
pub bundle_sha256: Option<String>,
pub notes: Option<String>,
pub released_at: Option<String>,
}
@ -148,6 +154,9 @@ pub struct UpdateStatus {
pub download_url: Option<String>,
pub download_size: Option<i64>,
pub sha256: Option<String>,
/// Bundle-URL (scripts + package.json tar.gz) — nur gesetzt im Nix-Wrapper-Modus
pub bundle_url: Option<String>,
pub bundle_sha256: Option<String>,
}
/// Download-Fortschritt
@ -187,6 +196,8 @@ pub async fn check_for_update() -> Result<UpdateStatus, String> {
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<UpdateStatus, String> {
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<UpdateStatus, String> {
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<UpdateStatus, String> {
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<UpdateStatus, String> {
// 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<String>,
) -> Result<(), String> {
use std::io::Write;
// Nur im Nix-Wrapper-Modus relevant — im AppImage sind Scripts+node_modules eh drin
if !is_nix_wrapper() {
return Ok(());
}
let target_dir = std::env::var("HOME")
.map(|h| PathBuf::from(h).join(".local/share/claude-desktop"))
.map_err(|e| format!("$HOME nicht gesetzt: {}", e))?;
std::fs::create_dir_all(&target_dir)
.map_err(|e| format!("Ziel-Verzeichnis konnte nicht angelegt werden: {}", e))?;
// === 1. Bundle herunterladen ===
app.emit("bundle-progress", "download").ok();
let client = reqwest::Client::new();
let response = match UPDATE_TOKEN {
Some(token) => {
let creds = B64.encode(format!("{}:{}", UPDATE_USER, token));
client
.get(&bundle_url)
.header("Authorization", format!("Basic {}", creds))
}
None => client.get(&bundle_url),
}
.send()
.await
.map_err(|e| format!("Bundle-Download-Fehler: {}", e))?;
if !response.status().is_success() {
return Err(format!("Bundle-HTTP-Fehler: {}", response.status()));
}
let bundle_tmp = std::env::temp_dir().join(format!("claude-desktop-bundle-{}.tar.gz", std::process::id()));
let bytes = response
.bytes()
.await
.map_err(|e| format!("Bundle-Body-Fehler: {}", e))?;
// SHA256 pruefen
if let Some(expected) = expected_sha256 {
let mut hasher = Sha256::new();
hasher.update(&bytes);
let actual = format!("{:x}", hasher.finalize());
if !actual.eq_ignore_ascii_case(&expected) {
return Err(format!(
"Bundle-SHA256-Mismatch: erwartet {}, bekommen {}",
expected, actual
));
}
}
std::fs::File::create(&bundle_tmp)
.and_then(|mut f| f.write_all(&bytes))
.map_err(|e| format!("Bundle-Zwischendatei: {}", e))?;
// === 2. Entpacken ===
app.emit("bundle-progress", "extract").ok();
let status = std::process::Command::new("tar")
.arg("-xzf")
.arg(&bundle_tmp)
.arg("-C")
.arg(&target_dir)
.status()
.map_err(|e| format!("tar konnte nicht gestartet werden: {}", e))?;
if !status.success() {
return Err(format!("tar-Extract schlug fehl (exit {})", status));
}
std::fs::remove_file(&bundle_tmp).ok();
// === 3. npm ci --omit=dev ===
app.emit("bundle-progress", "npm-install").ok();
let output = std::process::Command::new("npm")
.arg("ci")
.arg("--omit=dev")
.current_dir(&target_dir)
.output()
.map_err(|e| format!("npm konnte nicht gestartet werden: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"npm ci --omit=dev schlug fehl (exit {}): {}",
output.status,
stderr.lines().take(10).collect::<Vec<_>>().join("\n")
));
}
app.emit("bundle-progress", "done").ok();
println!("✅ Bundle aktualisiert in {:?}", target_dir);
Ok(())
}
/// Prüft ob eine Version ein gültiges Zeitstempel-Format hat (nur Ziffern und Bindestriche).
/// Beispiel: "20260420-1300" → true, "dev" → false, "dev-local" → false
fn is_timestamp_version(v: &str) -> bool {

View file

@ -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) {