#!/bin/sh set -e # Single-path daemon install: install.sh proves the desktop app can launch, # then launches it once with `--install-daemon`. The Kazzle app is the only # code that touches daemon files. install.sh never extracts daemon payloads, # never validates manifests, never registers OS services. # # 1. host prep (Linux only): apt-get / dnf / pacman / apk # + xvfb (so we can give the app a fake screen if there's no # real one — servers, CI, fresh sandboxes) # 2. artifact: place Kazzle.app / Kazzle.AppImage on disk (needs admin) # 3. drop privs: sudo -u $SUDO_USER (Linux), nothing on macOS # 4. launch: [xvfb-run -a if no $DISPLAY] Kazzle --install-daemon # --status-file /tmp/kazzle-install.json [--token X] # 5. wait: trust the app's exit code; print status-file reason on fail. # # Supported flags: # --token Pass a linking token through to the app/daemon. INSTALL_BASE_URL="${KAZZLE_INSTALL_URL:-https://download.kazzle.com}" TOKEN="" while [ $# -gt 0 ]; do case "$1" in --token) TOKEN="$2"; shift 2 ;; *) shift ;; esac done log() { echo "[kazzle] $1"; } detect_os() { case "$(uname -s)" in Darwin) echo "macos" ;; Linux) echo "linux" ;; *) echo "unsupported" ;; esac } detect_arch() { case "$(uname -m)" in x86_64|amd64) echo "x64" ;; arm64|aarch64) echo "arm64" ;; *) echo "unsupported" ;; esac } OS=$(detect_os) ARCH=$(detect_arch) if [ "$OS" = "unsupported" ] || [ "$ARCH" = "unsupported" ]; then echo "Error: unsupported platform $(uname -s) $(uname -m)" echo "Kazzle supports macOS (x64/arm64) and Linux (x64/arm64)." exit 1 fi # ---------------------------------------------------------------------------- # Linux: re-exec under sudo so steps 1-2 (apt-get + /opt write) can run. # Step 4 (app launch) drops privs back via sudo -u "$RUN_USER". # ---------------------------------------------------------------------------- if [ "$OS" = "linux" ] && [ "$(id -u)" -ne 0 ]; then log "Root needed for system packages and /opt; the app itself runs as you." SELF_SCRIPT=$(mktemp /tmp/kazzle-install-self.XXXXXX.sh) if [ -f "$0" ] && [ "$0" != "sh" ] && [ "$0" != "/bin/sh" ]; then cp "$0" "$SELF_SCRIPT" else if command -v curl >/dev/null 2>&1; then curl -fsSL "${KAZZLE_INSTALL_URL:-https://install.kazzle.app}" > "$SELF_SCRIPT" else wget -qO "$SELF_SCRIPT" "${KAZZLE_INSTALL_URL:-https://install.kazzle.app}" fi fi if [ -n "$TOKEN" ]; then exec sudo \ KAZZLE_INSTALL_URL="${KAZZLE_INSTALL_URL:-}" \ KAZZLE_HOME="${KAZZLE_HOME:-}" \ KAZZLE_RPC_PORT="${KAZZLE_RPC_PORT:-}" \ KAZZLE_ENV="${KAZZLE_ENV:-}" \ sh "$SELF_SCRIPT" --token "$TOKEN" fi exec sudo \ KAZZLE_INSTALL_URL="${KAZZLE_INSTALL_URL:-}" \ KAZZLE_HOME="${KAZZLE_HOME:-}" \ KAZZLE_RPC_PORT="${KAZZLE_RPC_PORT:-}" \ KAZZLE_ENV="${KAZZLE_ENV:-}" \ sh "$SELF_SCRIPT" fi log "Detected: $OS $ARCH" fetch() { if command -v curl >/dev/null 2>&1; then curl -fsSL "$@" elif command -v wget >/dev/null 2>&1; then wget -qO- "$@" else echo "Error: curl or wget required" exit 1 fi } download() { if command -v curl >/dev/null 2>&1; then curl -fsSL -o "$2" "$1" else wget -qO "$2" "$1" fi } # ---------------------------------------------------------------------------- # Step 1 — Linux host preparation # Source of truth: daemon/src/setup.ts (REQUIRED_PACKAGES.*). # `xvfb` is added so install.sh can give the app a temporary display when # launched on a server / CI / sandbox without an active desktop session. # ---------------------------------------------------------------------------- LINUX_DEPS_DEBIAN="libgtk-3-0 libgbm1 libnss3 libxss1 libasound2 libx11-xcb1 libxcb-dri3-0 libdrm2 libglib2.0-0 libatk1.0-0 libatk-bridge2.0-0 libcups2 libpango-1.0-0 libcairo2 libexpat1 libxrandr2 libxcomposite1 libxdamage1 libxfixes3 libxcursor1 libxi6 libxtst6 libxkbcommon0 xdg-utils tigervnc-standalone-server openbox xdotool imagemagick x11-utils websockify libnotify-bin xvfb" LINUX_DEPS_FEDORA="gtk3 mesa-libgbm nss libXScrnSaver alsa-lib tigervnc-server openbox xdotool ImageMagick xorg-x11-utils python3-websockify libnotify xorg-x11-server-Xvfb" LINUX_DEPS_ARCH="gtk3 nss alsa-lib tigervnc openbox xdotool imagemagick xorg-xdpyinfo python-websockify libnotify xorg-server-xvfb" LINUX_DEPS_ALPINE="gtk+3.0 nss alsa-lib tigervnc openbox xdotool imagemagick xdpyinfo websockify libnotify xvfb" linux_runtime_deps_present() { if ! command -v ldconfig >/dev/null 2>&1; then return 1 fi if ! ldconfig -p 2>/dev/null | grep -q 'libglib-2.0\.so\.0'; then return 1 fi for bin in Xvnc openbox xdotool websockify Xvfb; do if ! command -v "$bin" >/dev/null 2>&1; then return 1 fi done return 0 } # Some minimal base images (e.g. Blaxel sandbox squashfs) ship without # /var/lib/dpkg/status. Without it apt-get fails silently and Kazzle.app # fast-exits because libglib-2.0.so.0 is absent. ensure_dpkg_status() { if ! command -v dpkg >/dev/null 2>&1; then return 0 fi STATUS_FILE="${DPKG_STATUS_PATH:-/var/lib/dpkg/status}" if [ -s "$STATUS_FILE" ]; then log "dpkg status ok" return 0 fi STATUS_DIR=$(dirname "$STATUS_FILE") mkdir -p "$STATUS_DIR/updates" "$STATUS_DIR/info" if [ -s "$STATUS_DIR/status-old" ]; then log "Restoring dpkg status from status-old" cp "$STATUS_DIR/status-old" "$STATUS_FILE" return 0 fi BACKUP_DIR="${DPKG_BACKUP_PATH:-/var/backups}" if [ -d "$BACKUP_DIR" ]; then NEWEST_BACKUP=$(ls -t "$BACKUP_DIR"/dpkg.status.* 2>/dev/null | head -1) if [ -n "$NEWEST_BACKUP" ]; then log "Restoring dpkg status from $NEWEST_BACKUP" case "$NEWEST_BACKUP" in *.gz) gunzip -c "$NEWEST_BACKUP" > "$STATUS_FILE" ;; *) cp "$NEWEST_BACKUP" "$STATUS_FILE" ;; esac return 0 fi fi log "Bootstrapping empty dpkg status (no backup available)" : > "$STATUS_FILE" } apt_get_noninteractive() { DEBIAN_FRONTEND=noninteractive \ DEBCONF_NONINTERACTIVE_SEEN=true \ APT_LISTCHANGES_FRONTEND=none \ NEEDRESTART_MODE=a \ apt-get \ -y --no-install-recommends \ -o DPkg::Lock::Timeout=60 \ -o Dpkg::Options::=--force-confold \ -o Dpkg::Options::=--force-confdef \ "$@" } install_linux_runtime_deps() { if linux_runtime_deps_present; then log "linux deps already present" return 0 fi if command -v apt-get >/dev/null 2>&1; then ensure_dpkg_status log "Installing Linux runtime dependencies (apt)..." apt_get_noninteractive update || log "apt-get update reported errors; continuing" # shellcheck disable=SC2086 apt_get_noninteractive install $LINUX_DEPS_DEBIAN || log "apt-get install reported errors; continuing" elif command -v dnf >/dev/null 2>&1; then log "Installing Linux runtime dependencies (dnf)..." # shellcheck disable=SC2086 dnf install -y $LINUX_DEPS_FEDORA || log "dnf install reported errors; continuing" elif command -v pacman >/dev/null 2>&1; then log "Installing Linux runtime dependencies (pacman)..." # shellcheck disable=SC2086 pacman -Sy --noconfirm --needed $LINUX_DEPS_ARCH || log "pacman install reported errors; continuing" elif command -v apk >/dev/null 2>&1; then log "Installing Linux runtime dependencies (apk)..." # shellcheck disable=SC2086 apk add --no-cache $LINUX_DEPS_ALPINE || log "apk add reported errors; continuing" else log "No supported package manager (apt/dnf/pacman/apk); skipping Linux runtime dep install." fi } if [ "$OS" = "linux" ]; then install_linux_runtime_deps fi # ---------------------------------------------------------------------------- # Step 2 — fetch manifest, place desktop artifact # ---------------------------------------------------------------------------- MANIFEST_URL="$INSTALL_BASE_URL/latest.json" log "Fetching manifest from $MANIFEST_URL..." MANIFEST=$(fetch "$MANIFEST_URL") MANIFEST_KEY="linux" if [ "$OS" = "macos" ]; then MANIFEST_KEY="mac"; fi LATEST_VERSION=$(echo "$MANIFEST" | grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | grep -o '"[^"]*"$' | tr -d '"') FILENAME=$(echo "$MANIFEST" | grep -o "\"$MANIFEST_KEY\"[[:space:]]*:[[:space:]]*{[^}]*}" | grep -o '"filename"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -o '"[^"]*"$' | tr -d '"') if [ -z "$FILENAME" ]; then echo "Error: no download found for $MANIFEST_KEY in manifest" exit 1 fi DOWNLOAD_URL="${INSTALL_BASE_URL}/download/${FILENAME}" INSTALLED_VERSION="" if [ "$OS" = "macos" ] && [ -f "/Applications/Kazzle.app/Contents/Info.plist" ] && [ -x "/Applications/Kazzle.app/Contents/MacOS/Kazzle" ]; then INSTALLED_VERSION=$(grep -A1 CFBundleShortVersionString "/Applications/Kazzle.app/Contents/Info.plist" 2>/dev/null | grep string | sed 's/.*\(.*\)<\/string>.*/\1/' || echo "") fi if [ "$OS" = "linux" ] && [ -f "/opt/kazzle/VERSION" ] && [ -x "${KAZZLE_INSTALL_DIR:-/opt/kazzle}/app/AppRun" ]; then INSTALLED_VERSION=$(cat "/opt/kazzle/VERSION" 2>/dev/null || echo "") fi ARTIFACT_REFRESHED=0 if [ -n "$LATEST_VERSION" ] && [ "$INSTALLED_VERSION" = "$LATEST_VERSION" ]; then log "Desktop artifact already at v$INSTALLED_VERSION; skipping download." else if [ -n "$INSTALLED_VERSION" ]; then log "Updating desktop artifact from v$INSTALLED_VERSION to v$LATEST_VERSION..." fi log "Downloading from $DOWNLOAD_URL..." if [ "$OS" = "macos" ]; then INSTALL_DIR="/Applications" BUNDLE_NAME="Kazzle.app" LIVE_APP="$INSTALL_DIR/$BUNDLE_NAME" STAGE_APP="$INSTALL_DIR/$BUNDLE_NAME.new" OLD_APP="$INSTALL_DIR/$BUNDLE_NAME.old" TMP_FILE=$(mktemp /tmp/kazzle-install.XXXXXX.dmg) download "$DOWNLOAD_URL" "$TMP_FILE" log "Mounting DMG and staging Kazzle.app..." MOUNT_DIR=$(mktemp -d /tmp/kazzle-mount.XXXXXX) hdiutil attach "$TMP_FILE" -mountpoint "$MOUNT_DIR" -nobrowse -quiet rm -rf "$STAGE_APP" "$OLD_APP" cp -R "$MOUNT_DIR/$BUNDLE_NAME" "$STAGE_APP" hdiutil detach "$MOUNT_DIR" -quiet rm -f "$TMP_FILE" rmdir "$MOUNT_DIR" 2>/dev/null || true rm -rf "$OLD_APP" if [ -d "$LIVE_APP" ]; then mv "$LIVE_APP" "$OLD_APP" fi if ! mv "$STAGE_APP" "$LIVE_APP"; then rm -rf "$STAGE_APP" if [ -d "$OLD_APP" ]; then mv "$OLD_APP" "$LIVE_APP"; fi echo "Error: failed to swap staged Kazzle.app into place" exit 1 fi rm -rf "$OLD_APP" ARTIFACT_REFRESHED=1 else INSTALL_DIR="${KAZZLE_INSTALL_DIR:-/opt/kazzle}" APPIMAGE_PATH="$INSTALL_DIR/Kazzle.AppImage" APP_DIR="$INSTALL_DIR/app" TMP_FILE=$(mktemp /tmp/kazzle-install.XXXXXX.AppImage) download "$DOWNLOAD_URL" "$TMP_FILE" log "Installing AppImage to $INSTALL_DIR..." mkdir -p "$INSTALL_DIR" mv "$TMP_FILE" "$APPIMAGE_PATH" chmod +x "$APPIMAGE_PATH" # Extract the AppImage now (as root) into $APP_DIR. We launch the unpacked # binary directly to avoid the sudo + AppImage FUSE issue: when the runtime # stub is launched via `sudo -u `, the kernel sets PR_SET_DUMPABLE=0 # which makes /proc/self/exe unreadable, so the stub crashes with # "Cannot open /proc/self/exe: Permission denied" before any flag is read. # Extracting up front sidesteps FUSE entirely. EXTRACT_TMP=$(mktemp -d /tmp/kazzle-extract.XXXXXX) ( cd "$EXTRACT_TMP" "$APPIMAGE_PATH" --appimage-extract >/dev/null ) rm -rf "$APP_DIR" mv "$EXTRACT_TMP/squashfs-root" "$APP_DIR" rmdir "$EXTRACT_TMP" 2>/dev/null || true # Make sure non-root users can traverse + read + execute the unpacked tree. chmod -R a+rX "$APP_DIR" # Chromium sandbox needs setuid root + mode 4755 to work for non-root users. # AppImage extraction strips the setuid bit, so restore it here. if [ -f "$APP_DIR/chrome-sandbox" ]; then chown root:root "$APP_DIR/chrome-sandbox" 2>/dev/null || true chmod 4755 "$APP_DIR/chrome-sandbox" 2>/dev/null || true fi ln -sf "$APP_DIR/AppRun" "$INSTALL_DIR/kazzle" if [ -n "$LATEST_VERSION" ]; then echo "$LATEST_VERSION" > "$INSTALL_DIR/VERSION" fi ARTIFACT_REFRESHED=1 fi fi # ---------------------------------------------------------------------------- # Step 3 — figure out who to run the app as # Order: $SUDO_USER (set by sudo) > $LOGNAME / $USER > `logname`. If the # script is running as bare root with no breadcrumb back to a real user, # we keep the install task in the root context — Electron will still install # the daemon to /root/.kazzle and register a root systemd unit, which is # what CI/Docker/sandbox installs actually want. # ---------------------------------------------------------------------------- RUN_USER="" if [ "$OS" = "linux" ]; then if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then RUN_USER="$SUDO_USER" elif [ -n "${LOGNAME:-}" ] && [ "$LOGNAME" != "root" ]; then RUN_USER="$LOGNAME" elif [ -n "${USER:-}" ] && [ "$USER" != "root" ]; then RUN_USER="$USER" else LOGNAME_OUT=$(logname 2>/dev/null || echo "") if [ -n "$LOGNAME_OUT" ] && [ "$LOGNAME_OUT" != "root" ]; then RUN_USER="$LOGNAME_OUT" fi fi if [ -n "$RUN_USER" ]; then log "Will run app as user: $RUN_USER" else log "No non-root user detected; running app as root (CI / sandbox)." fi fi # ---------------------------------------------------------------------------- # Step 4 — launch the app to do the daemon install # ---------------------------------------------------------------------------- STATUS_FILE="${KAZZLE_INSTALL_STATUS_FILE:-/tmp/kazzle-install-status.$$.json}" rm -f "$STATUS_FILE" if [ "$OS" = "macos" ]; then APP_BIN="/Applications/Kazzle.app/Contents/MacOS/Kazzle" else # We extract the AppImage at install time (see Step 2) and launch the # unpacked binary so sudo-drop doesn't break /proc/self/exe. LINUX_INSTALL_DIR="${KAZZLE_INSTALL_DIR:-/opt/kazzle}" APP_BIN="$LINUX_INSTALL_DIR/app/AppRun" fi if [ ! -x "$APP_BIN" ]; then echo "Error: app binary not found or not executable: $APP_BIN" exit 1 fi LAUNCH_ARGS="--install-daemon --status-file $STATUS_FILE" if [ -n "$TOKEN" ]; then LAUNCH_ARGS="$LAUNCH_ARGS --token $TOKEN" fi if [ "$OS" = "linux" ] && [ -z "$RUN_USER" ]; then LAUNCH_ARGS="$LAUNCH_ARGS --no-sandbox" fi run_app() { if [ "$OS" = "linux" ]; then NEEDS_DISPLAY=0 if [ -z "${DISPLAY:-}" ] && [ -z "${WAYLAND_DISPLAY:-}" ]; then NEEDS_DISPLAY=1 fi # AppRun reads $APPDIR to find the inner electron binary; set it explicitly # because sudo strips most env vars on cross-user drops. Use `env` rather # than `sudo VAR=val` because the default sudoers policy may not honor # inline env without the `setenv` tag. Also forward KAZZLE_* so the app # picks up the same env/port/install-url the caller wanted. Forward # XDG_RUNTIME_DIR + DBUS_SESSION_BUS_ADDRESS when present so # `kazzle-daemon --autostart` can reach systemd --user after the sudo # drop instead of falling back to the non-reboot-persistent direct # supervisor. Some AppImage/AppRun environments otherwise leave DBus as a # bare path, which systemctl reports as "Unknown address type". APPDIR_ENV="$LINUX_INSTALL_DIR/app" FORWARDED_ENV="APPDIR=$APPDIR_ENV" [ -n "${KAZZLE_HOME:-}" ] && FORWARDED_ENV="$FORWARDED_ENV KAZZLE_HOME=$KAZZLE_HOME" [ -n "${KAZZLE_ENV:-}" ] && FORWARDED_ENV="$FORWARDED_ENV KAZZLE_ENV=$KAZZLE_ENV" [ -n "${KAZZLE_RPC_PORT:-}" ] && FORWARDED_ENV="$FORWARDED_ENV KAZZLE_RPC_PORT=$KAZZLE_RPC_PORT" [ -n "${KAZZLE_INSTALL_URL:-}" ] && FORWARDED_ENV="$FORWARDED_ENV KAZZLE_INSTALL_URL=$KAZZLE_INSTALL_URL" if [ -n "${XDG_RUNTIME_DIR:-}" ]; then FORWARDED_ENV="$FORWARDED_ENV XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus" fi if [ -n "$RUN_USER" ]; then if [ "$NEEDS_DISPLAY" = "1" ] && command -v xvfb-run >/dev/null 2>&1; then # shellcheck disable=SC2086 sudo -u "$RUN_USER" -H env $FORWARDED_ENV xvfb-run -a \ --server-args="-screen 0 1280x800x24" "$APP_BIN" $LAUNCH_ARGS else # shellcheck disable=SC2086 sudo -u "$RUN_USER" -H env $FORWARDED_ENV "$APP_BIN" $LAUNCH_ARGS fi else if [ "$NEEDS_DISPLAY" = "1" ] && command -v xvfb-run >/dev/null 2>&1; then # shellcheck disable=SC2086 env $FORWARDED_ENV xvfb-run -a --server-args="-screen 0 1280x800x24" \ "$APP_BIN" $LAUNCH_ARGS else # shellcheck disable=SC2086 env $FORWARDED_ENV "$APP_BIN" $LAUNCH_ARGS fi fi else # macOS: launch the binary inside the .app bundle directly so flags reach # main.cjs. `open -a Kazzle.app --args ...` does not pass flags through # reliably in CI / non-interactive contexts. # shellcheck disable=SC2086 "$APP_BIN" $LAUNCH_ARGS fi } log "Launching Kazzle to install the daemon..." APP_EXIT=0 run_app || APP_EXIT=$? if [ "$APP_EXIT" -ne 0 ]; then echo "Error: Kazzle install task exited $APP_EXIT" if [ -f "$STATUS_FILE" ]; then echo "Status: $(cat "$STATUS_FILE")" fi exit "$APP_EXIT" fi log "Installation complete!" if [ -n "$TOKEN" ]; then log "Daemon is connecting to server with provided token." else log "Open Kazzle and log in to connect this computer." fi