mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
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.
330 lines
10 KiB
Bash
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}"
|