Files
bambuddy/install/update_macos.sh
T
maziggy 1ccaf74dd5 fix(install): sign the Python that macOS grants local network access to (issue #3114)
macOS attributes Local Network permission to a code signature and judges a
launchd-spawned process on its own, rather than letting it inherit the grant
of the Terminal that started it. Homebrew ships Python unsigned on Intel, so
there is no identity for the grant to attach to: every connection to a LAN
address is dropped with no error the application can log and no permission
prompt. The printer reads as unreachable and nothing says why, and the entry
in Privacy & Security cannot be made to work because it refers to an identity
that no longer resolves.

install.sh signs during a macOS install; update_macos.sh re-checks on every
update, because `brew upgrade python` installs a fresh unsigned binary under
a new versioned path.

Both sign only what is currently unsigned. That gate is load-bearing: on
arm64 the linker ad-hoc signs every binary and the identity is a hash of the
file, so re-signing would rotate it and revoke a working grant on each update.
A python.org build carries a real Developer ID and must not be downgraded for
the same reason.

The interpreter and the framework's Python.app are both signed. The first is
what sys._base_executable resolves to and what the reporter's TCC log names;
the second is what his fix actually targeted. Which one macOS attributes
could not be established from either, and signing both costs nothing.

-----

fix(diagnostics): name the macOS permission that silently blocks the printer (issue #3114)

The port checks reported all three ports unreachable while the subnet check
passed, and port_mqtt's fix text sent the reporter after firewalls and IP
addresses. On a macOS native install that pattern has a cause neither of
those covers: no Local Network grant, denied with no error and no prompt.

A new macos_local_network check, appended on macOS only so no permanently
dimmed row appears for anyone else. It passes when the control port answered,
which is proof the permission is in place and means the signature probe never
runs on a healthy diagnostic. Otherwise it probes the interpreter: an
unsigned one gets the repair that fixes it, a signed one gets System Settings
— the arm64 case, where the identity is a hash of the binary, so a Python
upgrade presents macOS with a new application and strands the old grant.

Always warn, never fail, and only once port_mqtt has already failed, so this
can never be why a green diagnostic turns red. A printer that is simply
switched off produces the same all-ports-dead pattern, which is why the
signature, not the pattern, is what earns the specific advice. An
undeterminable signature is reported as the generic case rather than as
unsigned: that advice rewrites a file in the user's Python installation and
must not be offered on a guess.
2026-09-19 16:03:52 +02:00

330 lines
10 KiB
Bash

#!/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}"