From 2527a9820da7a31f9219699234508eda8704aa09 Mon Sep 17 00:00:00 2001 From: maziggy Date: Mon, 6 Jul 2026 12:59:22 +0200 Subject: [PATCH] fix(install): make macOS native install rootless (brew + venv permission errors) macOS mixed root-only steps (default /opt path, sudo git clone) with steps that must not run as root: brew refuses to run as root, and a root-owned venv/node_modules can't be managed by the launchd agent. The installer now refuses sudo on macOS, defaults to ~/bambuddy, and drops sudo from the download/venv/frontend/env/dir steps. A --path under a root-owned parent still works via a single elevate-and-chown. Linux (service user + systemd) unchanged. --- CHANGELOG.md | 1 + install/install.sh | 78 +++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 455a84ea2..477f33ce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b2] - Unreleased ### Fixed +- **Native install script fails on macOS with Homebrew/venv permission errors** — On macOS the installer mixed root-only steps (defaulting to `/opt/bambuddy`, which nudged users into `sudo ./install.sh`) with steps that must **not** run as root: `brew install` hard-refuses to run as root (aborting the script mid-way), and any venv / `node_modules` created by root can't be managed by the launchd agent (which runs as the user), producing permission errors on `pip`/`npm`. The macOS path is now fully rootless: the script refuses to run under `sudo` on macOS with an actionable message, defaults the install directory to `~/bambuddy` (user-owned, no `/opt` write), and the download/venv/frontend/env/directory steps no longer shell out to `sudo` on macOS. A custom `--path` under a root-owned parent still works — the script elevates only to create+chown that one directory to the user, then continues rootless. Linux behaviour (service user + systemd) is unchanged. - **External camera "connection lost" when the snapshot URL serves a non-JPEG image (#1902)** — An external camera configured in HTTP-snapshot mode failed to load in Bambuddy with a repeating "connection lost", even though the camera URL rendered fine when opened directly in a browser. The log showed `Snapshot does not appear to be JPEG` on every polled frame followed by the stream ending. Root cause: `_capture_snapshot` returned the fetched bytes even when they weren't JPEG, and the MJPEG stream wraps every part with a hard-coded `Content-Type: image/jpeg` boundary — so a camera serving PNG/WebP/BMP stills (common on IP cameras and reverse-proxied snapshot endpoints) sent the browser a non-JPEG payload labelled as JPEG, which the browser rejected, tearing down the whole `multipart/x-mixed-replace` stream. `_capture_snapshot` now transcodes non-JPEG stills to JPEG (via OpenCV, already a dependency) before streaming; genuine JPEG snapshots keep their byte-for-byte fast path, and truly undecodable responses (HTML error pages, auth redirects) fall back to the previous raw-return behaviour with a clearer one-off warning instead of a per-frame log flood. Fixes browser playback and keeps the JPEG-only downstream (plate detection, Obico, finish photo) working for these cameras. - **Per-user Notifications page unreachable from the sidebar (#1901, reporter @JmanB52D)** — The Notifications entry (where each user opts in/out of their own print email notifications) disappeared from the left navigation. The page (`/notifications`) and its API were both intact — only the sidebar link was gone, so the screen was reachable only by typing the URL. Root cause: the sidebar-ordering refactor in #1673 accidentally deleted the `notifications` item from `defaultNavItems` (and its `notifications:user_email` permission mapping) while extracting the ordering helpers, but left the advanced-auth visibility gate that references that id — so the gate had nothing to gate and the item could never render. Restored both the `defaultNavItems` entry and the permission gate; the item now shows for any user holding `notifications:user_email` (both default groups, Administrators and Operators, do) when advanced auth and user email notifications are enabled, exactly as before #1673. - **Virtual Printer FTP uploads silently truncated under uvloop — a corrupt `.gcode.3mf` was archived, queued, and forwarded to the real printer with a `226 Transfer complete` (#1896, reporter @dj-oyu)** — On a native venv install (not Docker), slicing in Bambu Studio and sending to a queue-mode VP produced a truncated upload: Bambuddy logged `226 Transfer complete`, archived the file, added it to the queue, and later pushed the corrupt file to the physical printer (A1 mini), which then failed to parse/start the job. Every truncated file ended at an exact multiple of 4096 bytes with a valid `PK\x03\x04` local header but no ZIP End-Of-Central-Directory record. **Root cause — isolated deterministically by the reporter.** uvloop's SSL layer discards already-received but still-buffered data when the client closes the data connection **without a TLS `close_notify`** (a "ragged EOF") while the reader is **flow-control-paused** (slow consumer). `cmd_STOR` writes each 64 KiB chunk to disk synchronously inside the read loop; on slow storage (the reporter's data dir was on a microSD on an ARM64 SBC) the reader falls behind, the transport pauses, and the tail of the upload is lost — `read()` then returns a clean empty EOF, so the loop exits normally with **no exception and no write error**, and the server acks 226 for a file it truncated itself. The reporter's isolation matrix reproduces it on a minimal uvloop 0.22.1 TLS server (slow reader → 2,248,704 of 2,500,001 bytes, 3/3 runs) but never on CPython's default asyncio loop or on uvloop with a fast reader — which is why Docker/x86-with-SSD deployments almost never hit it (the reader keeps up, flow control never pauses, the loss window never opens). Bambuddy's Dockerfile already runs `--loop asyncio`; **every native launch path did not** and so auto-selected uvloop via `uvicorn[standard]`. **Fix — two independent layers.** (1) *Remove the trigger.* Added `--loop asyncio` to every native launch path so uploads actually arrive intact, matching the Dockerfile: `deploy/bambuddy.service`, `install/install.sh` (systemd unit + macOS launchd plist), `spoolbuddy/install/install.sh` (the bundled Bambuddy backend service), `installers/windows/service/install-service.bat` (NSSM), `README.md`, and the wiki install docs (run command, systemd, launchd) — each with an inline "do not remove, see #1896" note. The reporter verified `--loop asyncio` fully resolves it (before: 8/8 real Bambu Studio uploads truncated; after: 2/2 intact, valid ZIPs, `testzip` clean). (2) *Defense in depth, loop-independent.* `cmd_STOR` now validates that a received `.3mf` opens as a ZIP (reads the central directory — O(dir), no decompression) **before** replying 226. A truncated/corrupt 3MF is treated exactly like a failed transfer: the file is dropped and the slicer gets `426 Transfer failed: uploaded 3MF is incomplete or corrupt`, and the `on_file_received` callback that archives/queues/forwards the job never runs — so a broken upload surfaces as an immediate, actionable slicer-side send error instead of a confusing printer-side parse failure later. Validation is scoped to `.3mf` uploads; other filetypes keep the prior pass-through behaviour. This layer protects anyone who still runs uvloop for any reason (custom launch command, future `uvicorn[standard]` default). **Tests.** `test_vp_ftp_stor.py`: the happy-path test now feeds a real multi-chunk ZIP and asserts 226 (not 426); new `test_stor_rejects_truncated_3mf` drops the EOCD-bearing tail and asserts 426 + file removed + `on_file_received` never called; new `test_stor_skips_zip_validation_for_non_3mf` asserts a plain `.gcode` still gets 226 (no false-positive). 6/6 in the file green, ruff clean. **Scope.** Backend (one validation block in `cmd_STOR` + `zipfile` import) plus launch-config across repo installers, the Windows/SpoolBuddy installers, README, and the install wiki. No DB migration, no new permission, no i18n key, no frontend change. Users on a native install: after upgrade, re-run the installer (or add `--loop asyncio` to your existing service command) to stop the truncation at the source; the ZIP-validation guard takes effect on the next Bambuddy restart regardless. **Workaround for older versions:** launch uvicorn with `--loop asyncio`. diff --git a/install/install.sh b/install/install.sh index 8d6fb773a..07a453f39 100755 --- a/install/install.sh +++ b/install/install.sh @@ -332,6 +332,26 @@ create_user() { log_success "Service user created" } +# Ensure a directory exists and is owned by the current user. Used on macOS so +# the install tree stays user-owned (git/venv/npm never touch a root-owned dir). +# Only elevates when the target's parent is root-owned (e.g. /opt); a path under +# $HOME is created without any sudo prompt. +ensure_user_owned_dir() { + local dir="$1" + + if [[ -d "$dir" ]] && [[ -w "$dir" ]]; then + return + fi + + if mkdir -p "$dir" 2>/dev/null; then + return + fi + + log_info "Creating $dir (requires your password)..." + sudo mkdir -p "$dir" + sudo chown "$(id -un):$(id -gn)" "$dir" +} + download_bambuddy() { log_info "Downloading BamBuddy..." @@ -343,7 +363,23 @@ download_bambuddy() { exit 1 fi - if [[ -d "$INSTALL_PATH/.git" ]]; then + if [[ "$OS_TYPE" == "macos" ]]; then + # macOS has no service user — the whole install runs as the current user. + # Create the target user-owned (elevating only if its parent is root-owned, + # e.g. /opt), then clone/update without sudo so the venv/frontend the user + # builds next aren't fighting a root-owned tree. + ensure_user_owned_dir "$INSTALL_PATH" + if [[ -d "$INSTALL_PATH/.git" ]]; then + log_info "Existing installation found, updating..." + git config --global --add safe.directory "$INSTALL_PATH" 2>/dev/null || true + cd "$INSTALL_PATH" + git fetch origin + git checkout "$BRANCH" 2>/dev/null || git checkout -b "$BRANCH" "origin/$BRANCH" + git reset --hard "origin/$BRANCH" + else + git clone --branch "$BRANCH" https://github.com/maziggy/bambuddy.git "$INSTALL_PATH" + fi + elif [[ -d "$INSTALL_PATH/.git" ]]; then log_info "Existing installation found, updating..." # Add safe.directory to avoid "dubious ownership" error when running as root git config --global --add safe.directory "$INSTALL_PATH" 2>/dev/null || true @@ -472,9 +508,12 @@ build_frontend() { create_directories() { log_info "Creating data directories..." - sudo mkdir -p "$DATA_DIR" "$LOG_DIR" - - if [[ "$OS_TYPE" != "macos" ]]; then + if [[ "$OS_TYPE" == "macos" ]]; then + # Rootless: DATA_DIR/LOG_DIR default under the user-owned install path. + ensure_user_owned_dir "$DATA_DIR" + ensure_user_owned_dir "$LOG_DIR" + else + sudo mkdir -p "$DATA_DIR" "$LOG_DIR" sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$DATA_DIR" "$LOG_DIR" fi @@ -503,11 +542,15 @@ LOG_LEVEL=$LOG_LEVEL LOG_TO_FILE=true EOF - sudo mv /tmp/bambuddy.env "$env_file" - if [[ "$OS_TYPE" != "macos" ]]; then + if [[ "$OS_TYPE" == "macos" ]]; then + # Rootless: install path is user-owned, so write it directly. + mv /tmp/bambuddy.env "$env_file" + chmod 600 "$env_file" + else + sudo mv /tmp/bambuddy.env "$env_file" sudo chown "$SERVICE_USER:$SERVICE_USER" "$env_file" + sudo chmod 600 "$env_file" fi - sudo chmod 600 "$env_file" log_success "Environment file created at $env_file" } @@ -845,6 +888,27 @@ main() { detect_os log_success "Detected: $OS_TYPE (package manager: $PKG_MANAGER)" + # macOS must run rootless. Homebrew hard-refuses to run as root, and a venv + # / node_modules created by root can't be managed by the launchd agent (which + # runs as the user). Bail early with an actionable message instead of dying + # halfway through on "brew: running as root is not supported". + if [[ "$OS_TYPE" == "macos" ]]; then + if [[ "$EUID" -eq 0 ]]; then + log_error "Don't run the macOS installer with sudo." + log_info "Homebrew, the Python venv, and the launchd agent must all be created as your" + log_info "normal user. Re-run without sudo (the script elevates only when it truly needs to):" + echo "" + echo " ./install.sh" + echo "" + exit 1 + fi + # /opt requires sudo to create and would leave a root-owned tree; default + # macOS installs to a user-owned location so the whole flow stays rootless. + if [[ "$DEFAULT_INSTALL_PATH" == "/opt/bambuddy" ]]; then + DEFAULT_INSTALL_PATH="$HOME/bambuddy" + fi + fi + # Check/install Python if ! detect_python; then log_info "Python 3.10+ not found, will install..."