update_macos.sh 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. #!/usr/bin/env bash
  2. set -Eeuo pipefail
  3. INSTALL_DIR="${INSTALL_DIR:-/opt/bambuddy}"
  4. SERVICE_NAME="${SERVICE_NAME:-com.bambuddy.app}"
  5. PLIST_PATH="${PLIST_PATH:-$HOME/Library/LaunchAgents/com.bambuddy.app.plist}"
  6. BRANCH="${BRANCH:-}"
  7. VENV_PIP="${VENV_PIP:-$INSTALL_DIR/venv/bin/pip}"
  8. FRONTEND_DIR="${FRONTEND_DIR:-$INSTALL_DIR/frontend}"
  9. BACKUP_DIR="${BACKUP_DIR:-$INSTALL_DIR/backups}"
  10. BAMBUDDY_API_URL="${BAMBUDDY_API_URL:-http://127.0.0.1:8000/api/v1}"
  11. BAMBUDDY_API_KEY="${BAMBUDDY_API_KEY:-}"
  12. BACKUP_MODE="${BACKUP_MODE:-auto}" # auto|require|skip
  13. BACKUP_KEEP_COUNT=5
  14. FORCE="${FORCE:-0}"
  15. SERVICE_STOPPED=0
  16. CODE_UPDATED=0
  17. old_commit=""
  18. log() {
  19. printf '[bambuddy-update] %s\n' "$*"
  20. }
  21. warn() {
  22. printf '[bambuddy-update] WARNING: %s\n' "$*" >&2
  23. }
  24. die() {
  25. printf '[bambuddy-update] ERROR: %s\n' "$*" >&2
  26. exit 1
  27. }
  28. require_cmd() {
  29. command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
  30. }
  31. cleanup_old_backups() {
  32. local -a backup_files
  33. local max_count="$1"
  34. [ "$max_count" -gt 0 ] || return 0
  35. mapfile -t backup_files < <(ls -1t "$BACKUP_DIR"/bambuddy-backup-*.zip 2>/dev/null || true)
  36. if [ "${#backup_files[@]}" -le "$max_count" ]; then
  37. return 0
  38. fi
  39. for old_file in "${backup_files[@]:$max_count}"; do
  40. rm -f "$old_file"
  41. done
  42. log "Pruned old backups, kept newest $max_count file(s)"
  43. }
  44. is_service_active() {
  45. launchctl list | grep -q "$SERVICE_NAME"
  46. }
  47. # Restore the --loop asyncio pin on a plist written before it existed (#3001).
  48. #
  49. # The macOS twin of the systemd repair in update.sh, and there for the same
  50. # reason: install.sh has pinned the loop since 2026-07-05 (#1896), this script
  51. # has never rewritten the plist, and nothing else does -- so an install created
  52. # before that date still launches on uvloop today. uvloop reaches macOS as
  53. # well, since uvicorn[standard] only excludes it on Windows. That costs every
  54. # RTSP camera (#3001) and risks silently truncated Virtual Printer FTP uploads
  55. # (#1896), neither of which is visible from outside the machine.
  56. #
  57. # PlistBuddy is used rather than sed because the plist is XML and
  58. # ProgramArguments is an array; appending the two strings is safe because
  59. # uvicorn accepts its options in any order after the app path.
  60. repair_loop_flag() {
  61. local plistbuddy="/usr/libexec/PlistBuddy" backup
  62. [ -f "$PLIST_PATH" ] || return 0
  63. if grep -q -- '--loop' "$PLIST_PATH"; then
  64. return 0
  65. fi
  66. if [ ! -x "$plistbuddy" ]; then
  67. warn "PlistBuddy not found; add '--loop' and 'asyncio' to ProgramArguments in $PLIST_PATH by hand. See #1896."
  68. return 0
  69. fi
  70. # A plist that does not invoke uvicorn directly is someone else's
  71. # arrangement and is described rather than edited.
  72. if ! grep -q 'uvicorn' "$PLIST_PATH"; then
  73. warn "$PLIST_PATH does not start uvicorn directly; add '--loop asyncio' to it by hand. See #1896."
  74. return 0
  75. fi
  76. backup="$PLIST_PATH.bak-$(date +%Y%m%d-%H%M%S)"
  77. cp -p "$PLIST_PATH" "$backup" || {
  78. warn "Could not back up $PLIST_PATH; leaving it alone."
  79. return 0
  80. }
  81. if ! "$plistbuddy" -c 'Add :ProgramArguments: string --loop' \
  82. -c 'Add :ProgramArguments: string asyncio' "$PLIST_PATH" >/dev/null 2>&1; then
  83. warn "Failed to edit $PLIST_PATH; restoring from $backup."
  84. cp -p "$backup" "$PLIST_PATH" || true
  85. return 0
  86. fi
  87. log "Added the missing '--loop asyncio' flag to $PLIST_PATH (was written before #1896; backup at $backup)"
  88. log "Without it Bambuddy runs on uvloop, which breaks RTSP cameras (#3001) and can truncate Virtual Printer FTP uploads (#1896)."
  89. }
  90. # Re-apply the ad-hoc Python signature macOS needs to grant Local Network
  91. # access (#3114).
  92. #
  93. # The macOS twin of sign_python_for_tcc in install.sh, and here for two
  94. # reasons rather than one. An install created before that step existed has an
  95. # unsigned interpreter and no other way to acquire one -- the same gap
  96. # repair_loop_flag covers above. And it recurs: `brew upgrade python` installs
  97. # a fresh unsigned binary under a new versioned path, so this has to be
  98. # checked on every update, not once at install time.
  99. #
  100. # Without it, on an Intel Mac, TCC has no identity to anchor the grant to,
  101. # drops every connection to the printer with no error and no prompt, and the
  102. # entry in Privacy & Security cannot be made to work: the printer is simply
  103. # unreachable and nothing in the log says why.
  104. #
  105. # Only signs what is unsigned. On arm64 every binary already carries an
  106. # ad-hoc signature whose identity is a hash of the file, so re-signing would
  107. # rotate it and revoke a working grant on every single update.
  108. repair_python_signature() {
  109. local python_bin base_exe framework target signed_any=0
  110. local -a targets=()
  111. python_bin="$INSTALL_DIR/venv/bin/python3"
  112. [ -x "$python_bin" ] || return 0
  113. if ! command -v codesign >/dev/null 2>&1; then
  114. warn "codesign not found; skipping the macOS Local Network signing check."
  115. warn "If the printer is unreachable, run 'xcode-select --install' and re-run this script."
  116. return 0
  117. fi
  118. 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
  119. { [ -n "$base_exe" ] && [ -e "$base_exe" ]; } || return 0
  120. targets+=("$base_exe")
  121. # .../Versions/3.13/bin/python3.13 -> .../Versions/3.13/Resources/Python.app
  122. framework="${base_exe%/bin/*}"
  123. if [ "$framework" != "$base_exe" ] && [ -d "$framework/Resources/Python.app" ]; then
  124. targets+=("$framework/Resources/Python.app")
  125. fi
  126. for target in "${targets[@]}"; do
  127. if codesign -dv "$target" >/dev/null 2>&1; then
  128. continue
  129. fi
  130. if codesign --force --sign - "$target" >/dev/null 2>&1; then
  131. log "Ad-hoc signed $target so macOS can grant Local Network access (#3114)"
  132. signed_any=1
  133. else
  134. warn "Could not sign $target; Bambuddy may be unable to reach the printer."
  135. warn "Run by hand: codesign --force --sign - \"$target\""
  136. fi
  137. done
  138. [ "$signed_any" -eq 0 ] || log "Restart any open Bambuddy page after this update; the signature changes only take effect on the restart below."
  139. return 0
  140. }
  141. on_error() {
  142. local exit_code="$1"
  143. if [ "$SERVICE_STOPPED" -eq 1 ]; then
  144. if [ "$CODE_UPDATED" -eq 1 ] && [ -n "$old_commit" ]; then
  145. warn "Update failed after code change, attempting rollback to $old_commit"
  146. git reset --hard "$old_commit" || warn "Rollback reset failed"
  147. fi
  148. warn "Update failed, attempting to restart service: $SERVICE_NAME"
  149. launchctl load "$PLIST_PATH" || true
  150. fi
  151. exit "$exit_code"
  152. }
  153. trap 'on_error $?' ERR
  154. create_backup() {
  155. local ts backup_file
  156. local -a auth_args=()
  157. if [ "$BACKUP_MODE" = "skip" ]; then
  158. log "Skipping backup (BACKUP_MODE=skip)"
  159. return 0
  160. fi
  161. if ! is_service_active; then
  162. if [ "$BACKUP_MODE" = "require" ]; then
  163. die "Service is not running; cannot call built-in backup API."
  164. fi
  165. warn "Service is not running; skipping built-in backup API call."
  166. return 0
  167. fi
  168. mkdir -p "$BACKUP_DIR"
  169. ts="$(date +%Y%m%d-%H%M%S)"
  170. backup_file="$BACKUP_DIR/bambuddy-backup-$ts.zip"
  171. [ -n "$BAMBUDDY_API_KEY" ] && auth_args=(-H "X-API-Key: $BAMBUDDY_API_KEY")
  172. log "Creating built-in backup via API: $backup_file"
  173. if curl --silent --show-error --fail --location \
  174. --connect-timeout 5 --max-time 900 \
  175. ${auth_args:+${auth_args[@]}} \
  176. "$BAMBUDDY_API_URL/settings/backup" \
  177. --output "$backup_file"; then
  178. log "Backup created successfully"
  179. cleanup_old_backups "$BACKUP_KEEP_COUNT"
  180. return 0
  181. fi
  182. rm -f "$backup_file"
  183. if [ "$BACKUP_MODE" = "require" ]; then
  184. die "Built-in backup API call failed (BACKUP_MODE=require)."
  185. fi
  186. warn "Built-in backup API call failed. Continuing because BACKUP_MODE=auto."
  187. }
  188. # NOTE: kept root check as-is (you can remove if desired)
  189. #[ "${EUID:-$(id -u)}" -eq 0 ] || die "Run as root (or with sudo)."
  190. case "$BACKUP_MODE" in
  191. auto|require|skip) ;;
  192. *) die "Invalid BACKUP_MODE '$BACKUP_MODE' (expected: auto, require, skip)." ;;
  193. esac
  194. require_cmd git
  195. require_cmd launchctl
  196. require_cmd curl
  197. [ -d "$INSTALL_DIR" ] || die "Install directory not found: $INSTALL_DIR"
  198. [ -f "$PLIST_PATH" ] || die "Service plist not found: $PLIST_PATH"
  199. cd "$INSTALL_DIR"
  200. [ -d .git ] || die "No git repository found in: $INSTALL_DIR"
  201. if [ -z "$BRANCH" ]; then
  202. BRANCH="$(git rev-parse --abbrev-ref HEAD)"
  203. [ "$BRANCH" = "HEAD" ] && BRANCH="main"
  204. fi
  205. # replaced systemctl show check
  206. if ! launchctl list | grep -q "$SERVICE_NAME" && [ ! -f "$PLIST_PATH" ]; then
  207. die "Service not found: $SERVICE_NAME"
  208. fi
  209. old_commit="$(git rev-parse --short HEAD || true)"
  210. log "Fetching latest code from origin/$BRANCH"
  211. git fetch --prune origin
  212. remote_commit="$(git rev-parse --short "origin/$BRANCH" || true)"
  213. log "Current commit: ${old_commit:-unknown}"
  214. log "Remote commit: ${remote_commit:-unknown}"
  215. if git diff --quiet HEAD "origin/$BRANCH"; then
  216. log "You are already running the latest version of Bambuddy."
  217. read -r -p "Do you want to run the update process anyway? [y/N]: " run_anyway
  218. case "${run_anyway:-}" in
  219. y|Y|yes|YES) ;;
  220. *) exit 0 ;;
  221. esac
  222. else
  223. read -r -p "An update for Bambuddy is available. Install now? [y/N]: " install_now
  224. case "${install_now:-}" in
  225. y|Y|yes|YES) ;;
  226. *) exit 0 ;;
  227. esac
  228. fi
  229. if [ -n "$(git status --porcelain)" ]; then
  230. if [ "$FORCE" != "1" ]; then
  231. read -r -p "Local edits were detected in your installation. Updating now will overwrite those edits. Continue? [y/N]: " answer
  232. case "${answer:-}" in
  233. y|Y|yes|YES) ;;
  234. *) die "Update cancelled by user." ;;
  235. esac
  236. else
  237. warn "Proceeding without prompt because FORCE=1."
  238. fi
  239. fi
  240. create_backup
  241. log "Stopping service: $SERVICE_NAME"
  242. launchctl unload "$PLIST_PATH"
  243. SERVICE_STOPPED=1
  244. log "Updating code to origin/$BRANCH"
  245. git reset --hard "origin/$BRANCH"
  246. CODE_UPDATED=1
  247. if [ -x "$VENV_PIP" ] && [ -f requirements.txt ]; then
  248. log "Updating Python dependencies"
  249. "$VENV_PIP" install -r requirements.txt
  250. else
  251. warn "Skipping Python dependency update (venv pip or requirements.txt missing)."
  252. fi
  253. if [ -f "$FRONTEND_DIR/package.json" ]; then
  254. if command -v npm >/dev/null 2>&1; then
  255. log "Building frontend"
  256. (
  257. cd "$FRONTEND_DIR"
  258. npm ci
  259. npm run build
  260. )
  261. else
  262. warn "Skipping frontend build (npm not installed)."
  263. fi
  264. else
  265. warn "Skipping frontend build (frontend/package.json not found)."
  266. fi
  267. repair_loop_flag
  268. repair_python_signature
  269. log "Starting service: $SERVICE_NAME"
  270. launchctl load "$PLIST_PATH"
  271. SERVICE_STOPPED=0
  272. launchctl list | grep "$SERVICE_NAME" || true
  273. new_commit="$(git rev-parse --short HEAD || true)"
  274. log "Update complete: ${old_commit:-unknown} -> ${new_commit:-unknown}"