#!/usr/bin/env bash set -Eeuo pipefail INSTALL_DIR="${INSTALL_DIR:-/opt/bambuddy}" SERVICE_NAME="${SERVICE_NAME:-com.bambuddy.app}" PLIST_PATH="${PLIST_PATH:-$HOME/Library/LaunchAgents/com.bambuddy.app.plist}" BRANCH="${BRANCH:-}" VENV_PIP="${VENV_PIP:-$INSTALL_DIR/venv/bin/pip}" FRONTEND_DIR="${FRONTEND_DIR:-$INSTALL_DIR/frontend}" BACKUP_DIR="${BACKUP_DIR:-$INSTALL_DIR/backups}" BAMBUDDY_API_URL="${BAMBUDDY_API_URL:-http://127.0.0.1:8000/api/v1}" BAMBUDDY_API_KEY="${BAMBUDDY_API_KEY:-}" BACKUP_MODE="${BACKUP_MODE:-auto}" # auto|require|skip BACKUP_KEEP_COUNT=5 FORCE="${FORCE:-0}" SERVICE_STOPPED=0 CODE_UPDATED=0 old_commit="" log() { printf '[bambuddy-update] %s\n' "$*" } warn() { printf '[bambuddy-update] WARNING: %s\n' "$*" >&2 } die() { printf '[bambuddy-update] ERROR: %s\n' "$*" >&2 exit 1 } require_cmd() { command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1" } cleanup_old_backups() { local -a backup_files local max_count="$1" [ "$max_count" -gt 0 ] || return 0 mapfile -t backup_files < <(ls -1t "$BACKUP_DIR"/bambuddy-backup-*.zip 2>/dev/null || true) if [ "${#backup_files[@]}" -le "$max_count" ]; then return 0 fi for old_file in "${backup_files[@]:$max_count}"; do rm -f "$old_file" done log "Pruned old backups, kept newest $max_count file(s)" } is_service_active() { launchctl list | grep -q "$SERVICE_NAME" } # Restore the --loop asyncio pin on a plist written before it existed (#3001). # # The macOS twin of the systemd repair in update.sh, and there for the same # reason: install.sh has pinned the loop since 2026-07-05 (#1896), this script # has never rewritten the plist, and nothing else does -- so an install created # before that date still launches on uvloop today. uvloop reaches macOS as # well, since uvicorn[standard] only excludes it on Windows. That costs every # RTSP camera (#3001) and risks silently truncated Virtual Printer FTP uploads # (#1896), neither of which is visible from outside the machine. # # PlistBuddy is used rather than sed because the plist is XML and # ProgramArguments is an array; appending the two strings is safe because # uvicorn accepts its options in any order after the app path. repair_loop_flag() { local plistbuddy="/usr/libexec/PlistBuddy" backup [ -f "$PLIST_PATH" ] || return 0 if grep -q -- '--loop' "$PLIST_PATH"; then return 0 fi if [ ! -x "$plistbuddy" ]; then warn "PlistBuddy not found; add '--loop' and 'asyncio' to ProgramArguments in $PLIST_PATH by hand. See #1896." return 0 fi # A plist that does not invoke uvicorn directly is someone else's # arrangement and is described rather than edited. if ! grep -q 'uvicorn' "$PLIST_PATH"; then warn "$PLIST_PATH does not start uvicorn directly; add '--loop asyncio' to it by hand. See #1896." return 0 fi backup="$PLIST_PATH.bak-$(date +%Y%m%d-%H%M%S)" cp -p "$PLIST_PATH" "$backup" || { warn "Could not back up $PLIST_PATH; leaving it alone." return 0 } if ! "$plistbuddy" -c 'Add :ProgramArguments: string --loop' \ -c 'Add :ProgramArguments: string asyncio' "$PLIST_PATH" >/dev/null 2>&1; then warn "Failed to edit $PLIST_PATH; restoring from $backup." cp -p "$backup" "$PLIST_PATH" || true return 0 fi log "Added the missing '--loop asyncio' flag to $PLIST_PATH (was written before #1896; backup at $backup)" log "Without it Bambuddy runs on uvloop, which breaks RTSP cameras (#3001) and can truncate Virtual Printer FTP uploads (#1896)." } # Re-apply the ad-hoc Python signature macOS needs to grant Local Network # access (#3114). # # The macOS twin of sign_python_for_tcc in install.sh, and here for two # reasons rather than one. An install created before that step existed has an # unsigned interpreter and no other way to acquire one -- the same gap # repair_loop_flag covers above. And it recurs: `brew upgrade python` installs # a fresh unsigned binary under a new versioned path, so this has to be # checked on every update, not once at install time. # # Without it, on an Intel Mac, TCC has no identity to anchor the grant to, # drops every connection to the printer with no error and no prompt, and the # entry in Privacy & Security cannot be made to work: the printer is simply # unreachable and nothing in the log says why. # # Only signs what is unsigned. On arm64 every binary already carries an # ad-hoc signature whose identity is a hash of the file, so re-signing would # rotate it and revoke a working grant on every single update. repair_python_signature() { local python_bin base_exe framework target signed_any=0 local -a targets=() python_bin="$INSTALL_DIR/venv/bin/python3" [ -x "$python_bin" ] || return 0 if ! command -v codesign >/dev/null 2>&1; then warn "codesign not found; skipping the macOS Local Network signing check." warn "If the printer is unreachable, run 'xcode-select --install' and re-run this script." return 0 fi base_exe="$("$python_bin" -c 'import os, sys; print(os.path.realpath(getattr(sys, "_base_executable", None) or sys.executable))' 2>/dev/null)" || return 0 { [ -n "$base_exe" ] && [ -e "$base_exe" ]; } || return 0 targets+=("$base_exe") # .../Versions/3.13/bin/python3.13 -> .../Versions/3.13/Resources/Python.app framework="${base_exe%/bin/*}" if [ "$framework" != "$base_exe" ] && [ -d "$framework/Resources/Python.app" ]; then targets+=("$framework/Resources/Python.app") fi for target in "${targets[@]}"; do if codesign -dv "$target" >/dev/null 2>&1; then continue fi if codesign --force --sign - "$target" >/dev/null 2>&1; then log "Ad-hoc signed $target so macOS can grant Local Network access (#3114)" signed_any=1 else warn "Could not sign $target; Bambuddy may be unable to reach the printer." warn "Run by hand: codesign --force --sign - \"$target\"" fi done [ "$signed_any" -eq 0 ] || log "Restart any open Bambuddy page after this update; the signature changes only take effect on the restart below." return 0 } on_error() { local exit_code="$1" if [ "$SERVICE_STOPPED" -eq 1 ]; then if [ "$CODE_UPDATED" -eq 1 ] && [ -n "$old_commit" ]; then warn "Update failed after code change, attempting rollback to $old_commit" git reset --hard "$old_commit" || warn "Rollback reset failed" fi warn "Update failed, attempting to restart service: $SERVICE_NAME" launchctl load "$PLIST_PATH" || true fi exit "$exit_code" } trap 'on_error $?' ERR create_backup() { local ts backup_file local -a auth_args=() if [ "$BACKUP_MODE" = "skip" ]; then log "Skipping backup (BACKUP_MODE=skip)" return 0 fi if ! is_service_active; then if [ "$BACKUP_MODE" = "require" ]; then die "Service is not running; cannot call built-in backup API." fi warn "Service is not running; skipping built-in backup API call." return 0 fi mkdir -p "$BACKUP_DIR" ts="$(date +%Y%m%d-%H%M%S)" backup_file="$BACKUP_DIR/bambuddy-backup-$ts.zip" [ -n "$BAMBUDDY_API_KEY" ] && auth_args=(-H "X-API-Key: $BAMBUDDY_API_KEY") log "Creating built-in backup via API: $backup_file" if curl --silent --show-error --fail --location \ --connect-timeout 5 --max-time 900 \ ${auth_args:+${auth_args[@]}} \ "$BAMBUDDY_API_URL/settings/backup" \ --output "$backup_file"; then log "Backup created successfully" cleanup_old_backups "$BACKUP_KEEP_COUNT" return 0 fi rm -f "$backup_file" if [ "$BACKUP_MODE" = "require" ]; then die "Built-in backup API call failed (BACKUP_MODE=require)." fi warn "Built-in backup API call failed. Continuing because BACKUP_MODE=auto." } # NOTE: kept root check as-is (you can remove if desired) #[ "${EUID:-$(id -u)}" -eq 0 ] || die "Run as root (or with sudo)." case "$BACKUP_MODE" in auto|require|skip) ;; *) die "Invalid BACKUP_MODE '$BACKUP_MODE' (expected: auto, require, skip)." ;; esac require_cmd git require_cmd launchctl require_cmd curl [ -d "$INSTALL_DIR" ] || die "Install directory not found: $INSTALL_DIR" [ -f "$PLIST_PATH" ] || die "Service plist not found: $PLIST_PATH" cd "$INSTALL_DIR" [ -d .git ] || die "No git repository found in: $INSTALL_DIR" if [ -z "$BRANCH" ]; then BRANCH="$(git rev-parse --abbrev-ref HEAD)" [ "$BRANCH" = "HEAD" ] && BRANCH="main" fi # replaced systemctl show check if ! launchctl list | grep -q "$SERVICE_NAME" && [ ! -f "$PLIST_PATH" ]; then die "Service not found: $SERVICE_NAME" fi old_commit="$(git rev-parse --short HEAD || true)" log "Fetching latest code from origin/$BRANCH" git fetch --prune origin remote_commit="$(git rev-parse --short "origin/$BRANCH" || true)" log "Current commit: ${old_commit:-unknown}" log "Remote commit: ${remote_commit:-unknown}" if git diff --quiet HEAD "origin/$BRANCH"; then log "You are already running the latest version of Bambuddy." read -r -p "Do you want to run the update process anyway? [y/N]: " run_anyway case "${run_anyway:-}" in y|Y|yes|YES) ;; *) exit 0 ;; esac else read -r -p "An update for Bambuddy is available. Install now? [y/N]: " install_now case "${install_now:-}" in y|Y|yes|YES) ;; *) exit 0 ;; esac fi if [ -n "$(git status --porcelain)" ]; then if [ "$FORCE" != "1" ]; then read -r -p "Local edits were detected in your installation. Updating now will overwrite those edits. Continue? [y/N]: " answer case "${answer:-}" in y|Y|yes|YES) ;; *) die "Update cancelled by user." ;; esac else warn "Proceeding without prompt because FORCE=1." fi fi create_backup log "Stopping service: $SERVICE_NAME" launchctl unload "$PLIST_PATH" SERVICE_STOPPED=1 log "Updating code to origin/$BRANCH" git reset --hard "origin/$BRANCH" CODE_UPDATED=1 if [ -x "$VENV_PIP" ] && [ -f requirements.txt ]; then log "Updating Python dependencies" "$VENV_PIP" install -r requirements.txt else warn "Skipping Python dependency update (venv pip or requirements.txt missing)." fi if [ -f "$FRONTEND_DIR/package.json" ]; then if command -v npm >/dev/null 2>&1; then log "Building frontend" ( cd "$FRONTEND_DIR" npm ci npm run build ) else warn "Skipping frontend build (npm not installed)." fi else warn "Skipping frontend build (frontend/package.json not found)." fi repair_loop_flag repair_python_signature log "Starting service: $SERVICE_NAME" launchctl load "$PLIST_PATH" SERVICE_STOPPED=0 launchctl list | grep "$SERVICE_NAME" || true new_commit="$(git rev-parse --short HEAD || true)" log "Update complete: ${old_commit:-unknown} -> ${new_commit:-unknown}"