feat(installer): scaffold Windows installer build pipeline

Lays down the Inno Setup + embedded Python pipeline for producing a
  self-contained Bambuddy Windows installer .exe. The installer ships
  an embedded Python 3.13, the pre-built React bundle, NSSM (service
  supervisor) and ffmpeg — no host Python or Node required on the
  target machine.

  Architecture:
  - Install: C:\Program Files\Bambuddy (admin install, one-time UAC)
  - Data:    C:\ProgramData\Bambuddy\data (preserved on uninstall)
  - Service: registered via NSSM, runs as LocalSystem, autostart on boot
  - UI:      browser at http://localhost:8000 (Start Menu shortcut)

  Files:
  - installers/windows/build.py            stages embedded Python + deps,
                                           frontend bundle, NSSM, ffmpeg
  - installers/windows/bambuddy.iss        Inno Setup compiler script
  - installers/windows/service/*.bat       NSSM register/deregister
  - .github/workflows/windows-installer.yml CI build on tag push + manual
                                           dispatch, uploads .exe artifact

  build.py hard-fails on non-Windows hosts; Wine cross-build is an
  unsupported escape hatch behind --allow-non-windows. v1 ships unsigned
  (SmartScreen warns on first run) — production signing will be wired up
  via SignPath OSS once the application is approved.

  See installers/windows/README.md for build prerequisites and the
  embedded-Python ._pth gotchas.
This commit is contained in:
maziggy
2026-06-10 12:13:53 +02:00
parent f898556941
commit 8711c54ec3
8 changed files with 704 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
name: Windows Installer
# Build the Windows installer .exe.
#
# Triggers:
# - Tag push matching v* (release builds, uploaded as a release asset)
# - Manual dispatch (for testing the build pipeline)
#
# The installer is unsigned until SignPath OSS approval lands. Once it
# does, add the SignPath GitHub Action between the ISCC step and the
# upload step.
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build:
runs-on: windows-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install Inno Setup
run: |
choco install innosetup --version=6.2.2 --no-progress -y
shell: pwsh
- name: Stage installer artifacts
working-directory: installers/windows
run: python build.py
shell: pwsh
- name: Compile installer (ISCC)
working-directory: installers/windows
run: |
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" bambuddy.iss
shell: pwsh
- name: Upload installer artifact
uses: actions/upload-artifact@v4
with:
name: bambuddy-windows-installer
path: installers/windows/build/output/*.exe
if-no-files-found: error
- name: Attach installer to release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: installers/windows/build/output/*.exe
fail_on_unmatched_files: true
+2
View File
@@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.5b1] - Unreleased
### Added
- **Windows installer build pipeline scaffolded** — Lays down the infrastructure for producing a self-contained Bambuddy Windows installer `.exe` that doesn't require Python, Node, or any other runtime on the target machine. The installer ships an embedded Python 3.13 distribution (matching the Dockerfile's `python:3.13-slim-trixie`), the pre-built React bundle, NSSM (service supervisor), and ffmpeg — everything Bambuddy needs to run end-to-end on a stock Windows 10/11 box. **Architecture:** install target `C:\Program Files\Bambuddy\`, data target `C:\ProgramData\Bambuddy\data\` (preserved on uninstall so reinstalls keep the database + archives), service registered via NSSM running as `LocalSystem` with autostart on boot (LocalSystem is required because the Virtual Printer feature needs to bind 322 / 990 / 8883, all privileged ports on Windows). Browser is the UI — Start Menu shortcut opens `http://localhost:8000`, no Tauri / Electron launcher in v1, which matches how every other Bambuddy platform already works. **Why this shape over a PowerShell `install.ps1`:** the script approach was tried first and abandoned. Each failure across the Windows host fleet is environmental drift (Python version mismatches, execution-policy variants, antivirus heuristics, missing MSVC runtimes, OneDrive-redirected `%APPDATA%`, ARM64 vs x64, PowerShell 5.1 vs 7.x semantics) — a script can't insulate against host state, and every fix you add for one machine breaks two others. The self-contained-bundle approach takes that whole class of failure off the table. **Files:** `installers/windows/build.py` stages everything under `installers/windows/build/staging/`, `installers/windows/bambuddy.iss` is the Inno Setup 6 script, `installers/windows/service/install-service.bat` + `uninstall-service.bat` wrap NSSM. `build.py` hard-fails on non-Windows hosts; cross-build under Wine is an unsupported escape hatch behind `--allow-non-windows`. **CI:** `.github/workflows/windows-installer.yml` runs on tag push (`v*`) and manual dispatch, uses `windows-latest`, downloads Inno Setup via Chocolatey, runs `build.py` + ISCC, uploads the `.exe` as both a workflow artifact and a release asset. **Scope clarification:** this commit lands the build infrastructure, not a verified-working installer. The first real Windows-box smoke test happens after merge by triggering the workflow manually and installing the artifact on a target box; known unknowns are pip-installing `opencv-python-headless` / `curl_cffi` / `asyncpg` / `cryptography` / `bcrypt` against embedded Python (the `_pth` file edits in `build.py` cover the common gotchas but real-runtime imports are where surprises surface), ffmpeg path lookup from a LocalSystem service, and NSSM `AppEnvironmentExtra` line-continuation in cmd.exe. **Signing:** v1 ships unsigned — Windows SmartScreen will warn "Windows protected your PC" on first run, click-through works. SignPath OSS application submitted 2026-06-10 to wire free EV signing into CI once approved (typical 1–3 week approval window). **What's explicitly NOT in v1:** Spoolman bundling (Bambuddy's internal-inventory mode is the v1 default on Windows; users who want Spoolman install it separately), in-place upgrade (uninstall + install cycle works, but in-place upgrade-on-top needs end-to-end verification before we promise it), port-conflict pre-check (deferred to v1.1 — port collisions surface at first service start and the user reads the NSSM stderr log under `C:\ProgramData\Bambuddy\logs\service-stderr.log`). See `installers/windows/README.md` for the full build pipeline.
- **Bambu Lab A2L support (#1684)** — Internal model code `N9`, serial prefix `26A19` (5 chars, same shape as H2C's late `31B8B`). Capabilities resolved from BambuStudio's `resources/profiles/BBL/machine/Bambu Lab A2L.json` cross-checked against Bambu's official A2L specs page: linear rail, single FDM extruder + integrated cutter/plotter head (the BambuStudio `use_double_extruder_default_texture: true` flag covers the dual TOOL HEADS, not dual filament extrusion — A2L must NOT route AMS to the deputy slot or firmware rejects with 07FF_8012). Specs page also confirms NO Ethernet (Wi-Fi 2.4 GHz 802.11 b/g/n only), `Low-Rate-Kamera` on the chamber-image protocol (port 6000, NOT RTSP:322), no heated chamber. **Registry updates**: `PRINTER_MODEL_MAP` + `PRINTER_MODEL_ID_MAP` + `LINEAR_RAIL_MODELS` in `utils/printer_models.py`; `MODEL_TO_API_KEY` + `API_KEY_TO_DEV_MODEL` + `API_KEY_TO_WIKI_PATH` in `firmware_check.py` (wiki path follows the established `/en/a2l/manual/a2l-firmware-release-history` pattern; the existing 404 handling in `_fetch_all_versions_from_wiki` makes this safe to ship before Bambu publishes the page); `VIRTUAL_PRINTER_MODELS` + `MODEL_SERIAL_PREFIXES` in `virtual_printer/manager.py` (prefix `26A19A` with the same revision-letter padding as X2D's `20P90A`); `MODEL_PRODUCT_NAMES` in `virtual_printer/mqtt_server.py`; `mapModelCode` + Add-Printer / Edit-Printer model dropdowns in `PrintersPage.tsx` (new "A2 Series" optgroup); `mapModelCode` in `SpoolBuddyAmsPage.tsx`. **Camera and dual-nozzle code paths need no edits**: `supports_rtsp()` correctly falls through to chamber-image for A2L because `N9` is neither in the internal-code RTSP set nor does the display name match the X1/X2/H2/P2 prefix tuple; `is_dual_nozzle_model()` correctly returns False because A2L is not in `DUAL_NOZZLE_MODELS`. The cutter/plotter capability surfaces in MQTT push fields Bambuddy doesn't yet model; ignored for v1, will surface as a follow-up only if a real-world A2L bundle reveals a confusing UI state. **Tests**: 12 new cases in `test_printer_models.py::TestA2LModel` pinning every dimension — rod type, model-id round-trip, both ethernet directions, both camera-port directions, the explicit non-dual-nozzle guard (regression guard for the BambuStudio profile flag misread), set membership in `LINEAR_RAIL_MODELS` and exclusion from `CARBON_ROD_MODELS` / `STEEL_ROD_MODELS`.
- **One-shot `device.*` identification probe in MQTT push parser (#1684 enabler)** — Adding support for a new Bambu printer model needs the internal model code the firmware sends in MQTT `device.dev_model_name` (e.g. A1 is `N2S`, H2C is `O1C`, X2D is `N6`). The field arrives on every push but Bambuddy never logged it, so even a debug-enabled support bundle from a new-model user (A2L on #1684 was the case that surfaced this) gave us no way to identify the model — `get_version` was also missing because the printer disconnected right after the request topic subscription, which is a separate firmware quirk. **Fix:** at the top of the existing `device.*` parsing block in `bambu_mqtt.py`, emit one INFO log per client session dumping `dev_model_name` / `dev_product_name` / `dev_id` / `project_name` if any are present; otherwise fall back to `device.keys()` so a future Bambu rename (e.g. `model_name` without the `dev_` prefix) still surfaces. INFO level so the line lands in every support bundle, not just debug-enabled ones; one-shot via a `_device_id_logged` flag matching the existing `_nozzle_fields_logged` pattern at line 2095 — no spam at every push_status. 3 unit tests in `TestDeviceIdentificationProbe` pin the one-shot behaviour, the known-id-field path, and the keys-fallback path. Full `test_bambu_mqtt.py` suite 281 / 281 green; ruff clean. Once this ships, a new-model issue self-resolves from the first bundle — no second round of "please enable debug and reupload" required.
+2
View File
@@ -0,0 +1,2 @@
# Build artifacts — large and reproducible from build.py
build/
+84
View File
@@ -0,0 +1,84 @@
# Bambuddy Windows Installer
Builds a self-contained Windows installer (`.exe`) for Bambuddy: embedded
Python 3.13 distribution + pre-built frontend + NSSM-supervised Windows
service. No Python or Node installation required on the target machine.
## Architecture
- **Install target:** `C:\Program Files\Bambuddy\`
- **Data target:** `C:\ProgramData\Bambuddy\data\` (preserved on uninstall by default)
- **Logs target:** `C:\ProgramData\Bambuddy\logs\`
- **Service:** registered via NSSM, runs as `LocalSystem`, autostart on boot
- **Service command:** `python.exe -m uvicorn backend.app.main:app --host 0.0.0.0 --port 8000`
- **Bundled binaries:** Python 3.13 embeddable, NSSM, ffmpeg static build
Browser is the UI. Start Menu shortcut opens `http://localhost:8000`.
## Why these choices
See `memory/windows-installer-decision.md` for the full reasoning. Short
version: PowerShell install scripts can't survive environmental drift
across the Windows host fleet, so we ship a self-contained bundle that
depends on nothing on the host. Inno Setup + embedded Python is the
lowest-maintenance path that delivers native-app UX. No Tauri/Electron
launcher in v1 — browser-as-UI matches every other Bambuddy platform.
## Build prerequisites
The build runs on Windows (or in a Windows GitHub Actions runner). Cross-
building from Linux is possible via Wine but not officially supported.
- Windows 10/11 x64 (or `windows-latest` GitHub Actions runner)
- Python 3.11+ (for running `build.py`; the embedded Python that ships
in the installer is downloaded fresh by the build script)
- Node.js 22 LTS + npm (for building the frontend bundle)
- [Inno Setup 6](https://jrsoftware.org/isdl.php) (for compiling
`bambuddy.iss` → `.exe`)
The build script downloads everything else automatically (embedded Python,
NSSM, ffmpeg).
## Build steps
```cmd
:: From the repo root on a Windows machine
cd installers\windows
python build.py
:: Then open bambuddy.iss in Inno Setup Compiler and click Build → Compile
:: (or invoke ISCC.exe directly:)
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe" bambuddy.iss
```
Output: `installers\windows\build\output\bambuddy-windows-setup.exe`
## Testing without signing
The installer can be built and run unsigned. Windows SmartScreen will
show "Windows protected your PC" on first run. Click **More info** →
**Run anyway** to proceed. This is expected and harmless for testing.
Production builds will be signed via SignPath OSS (application in
flight as of 2026-06-10) and won't show this warning after reputation
accrues.
## CI build
See `.github/workflows/windows-installer.yml` for the automated build.
The workflow runs on every tag matching `v*` and uploads the installer
as a release asset.
## Known limitations / open questions
- **VP feature on Windows:** the Virtual Printer needs to bind 322/990/8883
(privileged ports). Service runs as LocalSystem which can bind these
ports, but the user's Windows Firewall will prompt on first VP enable.
Documenting this is TBD.
- **Spoolman:** explicitly NOT bundled in v1. Users who want Spoolman
install it separately. Bambuddy internal-inventory mode is the default
on Windows.
- **Bundle size:** estimated 250–350MB installed (mostly opencv +
ffmpeg + matplotlib). Acceptable for a v1; can investigate slimming
later if users complain.
- **Updates:** v1 ships as a fresh install / uninstall + install cycle.
In-place upgrade via the same installer is supported by Inno Setup but
needs end-to-end testing before we promise it.
+128
View File
@@ -0,0 +1,128 @@
; Bambuddy Windows Installer — Inno Setup script
;
; Builds a self-contained installer that lays down:
; - embedded Python 3.13 + pre-installed venv
; - backend source + pre-built frontend bundle
; - NSSM + ffmpeg under bin/
; - a Windows service running as LocalSystem
;
; Build prerequisites: run installers/windows/build.py first to stage
; the build/staging/ tree, then compile this file with ISCC.exe.
;
; See installers/windows/README.md for the full pipeline.
#define MyAppName "Bambuddy"
#define MyAppPublisher "Martin Ziegler"
#define MyAppURL "https://bambuddy.cool"
#define MyAppExeName "bambuddy.exe"
#define ServiceName "Bambuddy"
#define DefaultPort "8000"
; Version is stamped by build.py into build\staging\version.iss as a
; #define directive. Falls back to a placeholder if you ran ISCC without
; running build.py first (don't ship that build).
#ifexist "build\staging\version.iss"
#include "build\staging\version.iss"
#else
#define MyAppVersion "0.0.0+dev"
#endif
[Setup]
AppId={{8C9C9E1A-7C5A-4F2A-9F1B-BAMBUDDY00001}}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\Bambuddy
DefaultGroupName={#MyAppName}
DisableProgramGroupPage=yes
LicenseFile=..\..\LICENSE
OutputDir=build\output
OutputBaseFilename=bambuddy-{#MyAppVersion}-windows-x64-setup
Compression=lzma
SolidCompression=yes
WizardStyle=modern
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
; Admin required: we register a Windows service and write to ProgramData
PrivilegesRequired=admin
PrivilegesRequiredOverridesAllowed=
UninstallDisplayIcon={app}\bin\nssm.exe
SetupIconFile=
; Don't allow installing to a network drive — service won't start cleanly
DisableDirPage=no
DisableReadyPage=no
ChangesEnvironment=no
CloseApplications=no
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
Name: "german"; MessagesFile: "compiler:Languages\German.isl"
[Tasks]
Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional shortcuts:"; Flags: unchecked
Name: "firewallrule"; Description: "Add Windows Firewall rule for Bambuddy (port {#DefaultPort})"; GroupDescription: "Network:"
[Files]
; Embedded Python (entire tree)
Source: "build\staging\python\*"; DestDir: "{app}\python"; Flags: recursesubdirs ignoreversion
; Backend + frontend
Source: "build\staging\app\*"; DestDir: "{app}\app"; Flags: recursesubdirs ignoreversion
; NSSM, ffmpeg, ffprobe
Source: "build\staging\bin\*"; DestDir: "{app}\bin"; Flags: recursesubdirs ignoreversion
; Service install/uninstall scripts
Source: "build\staging\service\*"; DestDir: "{app}\service"; Flags: recursesubdirs ignoreversion
; Version stamp
Source: "build\staging\VERSION"; DestDir: "{app}"; Flags: ignoreversion
[Dirs]
; ProgramData layout — created with permissions LocalSystem can write to
Name: "{commonappdata}\Bambuddy"; Permissions: users-modify
Name: "{commonappdata}\Bambuddy\data"; Permissions: users-modify
Name: "{commonappdata}\Bambuddy\logs"; Permissions: users-modify
[Icons]
Name: "{group}\Open Bambuddy Dashboard"; Filename: "http://localhost:{#DefaultPort}"; IconFilename: "{app}\bin\nssm.exe"
Name: "{group}\Bambuddy Logs"; Filename: "{commonappdata}\Bambuddy\logs"
Name: "{group}\Uninstall Bambuddy"; Filename: "{uninstallexe}"
Name: "{commondesktop}\Bambuddy"; Filename: "http://localhost:{#DefaultPort}"; IconFilename: "{app}\bin\nssm.exe"; Tasks: desktopicon
[Run]
; Register and start the Windows service
Filename: "{app}\service\install-service.bat"; Parameters: """{app}"" ""{commonappdata}\Bambuddy"" {#DefaultPort}"; Flags: runhidden waituntilterminated; StatusMsg: "Registering Bambuddy service..."
; Open Windows Firewall on the dashboard port. We do this only if the
; user opted in via the firewallrule task — some environments manage
; firewall centrally and prefer to handle this themselves.
Filename: "netsh.exe"; Parameters: "advfirewall firewall add rule name=""Bambuddy Dashboard"" dir=in action=allow protocol=TCP localport={#DefaultPort}"; Flags: runhidden waituntilterminated; Tasks: firewallrule; StatusMsg: "Adding firewall rule..."
; Open the dashboard in the user's default browser at the end of install
Filename: "http://localhost:{#DefaultPort}"; Flags: shellexec postinstall nowait skipifsilent; Description: "Open Bambuddy Dashboard"
[UninstallRun]
; Stop + deregister the service before file removal
Filename: "{app}\service\uninstall-service.bat"; Parameters: """{app}"""; Flags: runhidden waituntilterminated
; Remove the firewall rule (silently — if it doesn't exist, netsh just complains)
Filename: "netsh.exe"; Parameters: "advfirewall firewall delete rule name=""Bambuddy Dashboard"""; Flags: runhidden waituntilterminated
[UninstallDelete]
; Remove install dir contents; leave ProgramData\Bambuddy alone so the
; user keeps their database + archives. Re-installing on top picks them
; back up automatically.
Type: filesandordirs; Name: "{app}"
[Code]
// Pre-install check: refuse to install if port 8000 is already in use by
// something other than a previous Bambuddy install. This catches the
// "I have something else on 8000" case early instead of after install.
function InitializeSetup(): Boolean;
begin
Result := True;
// TODO: optional port-conflict check. Inno Setup doesn't have a
// native socket API; would need a tiny helper exe or a netstat parse.
// Defer to v1.1 — for v1, accept that conflicts surface at first
// service start and the user reads the log.
end;
+325
View File
@@ -0,0 +1,325 @@
"""Build script for the Bambuddy Windows installer.
Stages all artifacts under ``installers/windows/build/staging/`` for the
Inno Setup compiler to package. Run this on Windows (or in a Windows CI
runner) — it pip-installs Bambuddy's deps against the embedded Python it
downloads, which requires the matching platform.
Steps:
1. Download python.org embeddable distribution for Windows x64
2. Configure embedded Python (allow site-packages)
3. Bootstrap pip into the embedded distribution
4. Install ``requirements.txt`` into the embedded Python
5. Build the React frontend (``frontend/npm run build``)
6. Stage backend source + frontend bundle
7. Download NSSM
8. Download ffmpeg static build for Windows
9. Print "ready for ISCC" message
After this script succeeds, run::
"C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe" bambuddy.iss
to produce the final installer .exe under ``build/output/``.
"""
from __future__ import annotations
import argparse
import shutil
import subprocess
import sys
import urllib.request
import zipfile
from pathlib import Path
# Repo root: installers/windows/build.py -> ../../
REPO_ROOT = Path(__file__).resolve().parents[2]
INSTALLER_DIR = Path(__file__).resolve().parent
BUILD_DIR = INSTALLER_DIR / "build"
STAGING = BUILD_DIR / "staging"
DOWNLOADS = BUILD_DIR / "downloads"
# Python 3.13 — matches Dockerfile (python:3.13-slim-trixie). Bump when
# the Dockerfile bumps; the Windows installer should track production.
PYTHON_VERSION = "3.13.1"
PYTHON_EMBED_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip"
# NSSM 2.24 is the long-time stable build. The official site has been
# unreliable; use the GitHub mirror that nssm.cc itself links to.
NSSM_VERSION = "2.24"
NSSM_URL = f"https://nssm.cc/release/nssm-{NSSM_VERSION}.zip"
# ffmpeg static build. BtbN's gyan-equivalent build is the most reliable
# automated source. Pin to a release tag so builds are reproducible.
FFMPEG_URL = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
# get-pip.py for bootstrapping pip into the embedded distribution
GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py"
def log(msg: str) -> None:
print(f"[build] {msg}", flush=True)
def download(url: str, dest: Path) -> Path:
"""Download ``url`` to ``dest`` if not already present."""
if dest.exists():
log(f"already downloaded: {dest.name}")
return dest
dest.parent.mkdir(parents=True, exist_ok=True)
log(f"downloading {url}")
with urllib.request.urlopen(url) as resp, open(dest, "wb") as f: # noqa: S310 — pinned URLs
shutil.copyfileobj(resp, f)
return dest
def unzip(zip_path: Path, dest: Path) -> None:
log(f"unzipping {zip_path.name} -> {dest}")
dest.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(dest)
def stage_embedded_python() -> Path:
"""Download and configure the embedded Python distribution."""
target = STAGING / "python"
if target.exists():
shutil.rmtree(target)
zip_path = download(
PYTHON_EMBED_URL,
DOWNLOADS / f"python-{PYTHON_VERSION}-embed-amd64.zip",
)
unzip(zip_path, target)
# Edit pythonXY._pth to allow site-packages. The embedded distribution
# ships with `import site` commented out — uncomment it so pip-installed
# packages in Lib\site-packages are importable.
pth_files = list(target.glob("python3*._pth"))
if not pth_files:
raise RuntimeError(f"no python3*._pth file found in {target}")
pth = pth_files[0]
content = pth.read_text()
content = content.replace("#import site", "import site")
# Also add Lib\site-packages explicitly. The embedded distribution
# doesn't include this path by default even with `import site` enabled.
if "Lib\\site-packages" not in content and "Lib/site-packages" not in content:
content = content.rstrip() + "\nLib\\site-packages\n"
pth.write_text(content)
# Bootstrap pip
get_pip = download(GET_PIP_URL, DOWNLOADS / "get-pip.py")
log("bootstrapping pip into embedded Python")
subprocess.run(
[str(target / "python.exe"), str(get_pip), "--no-warn-script-location"],
check=True,
)
return target
def install_requirements(python_dir: Path) -> None:
"""Install Bambuddy's requirements.txt into the embedded Python."""
py = python_dir / "python.exe"
requirements = REPO_ROOT / "requirements.txt"
log(f"installing requirements.txt into {python_dir}")
subprocess.run(
[
str(py),
"-m",
"pip",
"install",
"--no-warn-script-location",
"-r",
str(requirements),
],
check=True,
)
def build_frontend() -> Path:
"""Run ``npm ci && npm run build`` and return the dist path."""
frontend = REPO_ROOT / "frontend"
dist = frontend / "dist"
log("running npm ci in frontend/")
npm = shutil.which("npm")
if not npm:
raise RuntimeError("npm not found on PATH — install Node.js 22 LTS")
subprocess.run([npm, "ci"], cwd=frontend, check=True, shell=False)
log("running npm run build in frontend/")
subprocess.run([npm, "run", "build"], cwd=frontend, check=True, shell=False)
if not dist.exists():
raise RuntimeError(f"expected frontend build output at {dist}")
return dist
def stage_backend(frontend_dist: Path) -> None:
"""Copy backend source + frontend bundle into the staging tree.
The runtime layout under STAGING/app/ mirrors a Bambuddy checkout:
``backend/`` (source), ``static/`` (frontend bundle served by FastAPI).
"""
app = STAGING / "app"
if app.exists():
shutil.rmtree(app)
app.mkdir(parents=True)
# Backend source — copy the package tree, skip caches/tests/migrations
log("staging backend source")
shutil.copytree(
REPO_ROOT / "backend",
app / "backend",
ignore=shutil.ignore_patterns(
"__pycache__",
"*.pyc",
"tests",
".pytest_cache",
),
)
# Frontend bundle — FastAPI's StaticFiles mounts from app/static
log("staging frontend bundle")
shutil.copytree(frontend_dist, app / "static")
def stage_nssm() -> None:
target = STAGING / "bin"
target.mkdir(parents=True, exist_ok=True)
zip_path = download(NSSM_URL, DOWNLOADS / f"nssm-{NSSM_VERSION}.zip")
extract = DOWNLOADS / f"nssm-{NSSM_VERSION}-extracted"
if not extract.exists():
unzip(zip_path, extract)
# The zip nests as nssm-2.24/win64/nssm.exe
src = next(extract.rglob("win64/nssm.exe"))
log(f"staging nssm.exe from {src}")
shutil.copy(src, target / "nssm.exe")
def stage_ffmpeg() -> None:
target = STAGING / "bin"
target.mkdir(parents=True, exist_ok=True)
zip_path = download(FFMPEG_URL, DOWNLOADS / "ffmpeg-win64-gpl.zip")
extract = DOWNLOADS / "ffmpeg-extracted"
if not extract.exists():
unzip(zip_path, extract)
src = next(extract.rglob("bin/ffmpeg.exe"))
log(f"staging ffmpeg.exe from {src}")
shutil.copy(src, target / "ffmpeg.exe")
# ffprobe is used by some camera/timelapse paths
ffprobe = next(extract.rglob("bin/ffprobe.exe"), None)
if ffprobe is not None:
shutil.copy(ffprobe, target / "ffprobe.exe")
def stage_service_scripts() -> None:
"""Copy the service install/uninstall .bat files into staging."""
service_src = INSTALLER_DIR / "service"
service_dst = STAGING / "service"
if service_dst.exists():
shutil.rmtree(service_dst)
shutil.copytree(service_src, service_dst)
def write_version_file() -> None:
"""Write the installer version as both a plain VERSION file and an
Inno Setup include file so the .iss script can pick it up at compile
time without a fragile file-read hack.
Reads from pyproject.toml's [project] version line for the source of
truth. Falls back to ``0.0.0+dev`` if not parseable.
"""
version = "0.0.0+dev"
pyproject = REPO_ROOT / "pyproject.toml"
if pyproject.exists():
for line in pyproject.read_text().splitlines():
line = line.strip()
if line.startswith("version =") or line.startswith('version="'):
# version = "0.1.5" -> 0.1.5
version = line.split("=", 1)[1].strip().strip('"').strip("'")
break
(STAGING / "VERSION").write_text(version)
# Inno Setup include — bambuddy.iss does `#include "build\staging\version.iss"`
iss_version = STAGING / "version.iss"
iss_version.write_text(f'#define MyAppVersion "{version}"\n')
log(f"staged VERSION = {version}")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--skip-frontend",
action="store_true",
help="Skip frontend build (use existing frontend/dist/)",
)
parser.add_argument(
"--skip-pip",
action="store_true",
help="Skip pip install (use existing staged Python)",
)
parser.add_argument(
"--allow-non-windows",
action="store_true",
help=(
"Override the Windows-only guard. Only useful if you have a "
"working wine + windows-python toolchain. Not exercised by CI."
),
)
args = parser.parse_args()
if sys.platform != "win32" and not args.allow_non_windows:
log("ERROR: this build script must run on Windows.")
log("")
log("It downloads a Windows embeddable Python distribution and")
log("pip-installs Bambuddy's requirements.txt against it — both")
log("require executing python.exe, which only runs on Windows.")
log("")
log("Supported build paths:")
log(" 1. GitHub Actions: trigger '.github/workflows/windows-")
log(" installer.yml' (Actions tab -> Windows Installer ->")
log(" Run workflow). Downloads the .exe as a workflow artifact.")
log(" 2. Windows VM / box: clone, install Python 3.13 + Node 22 +")
log(" Inno Setup 6, run this script.")
log("")
log("Unsupported escape hatch (cross-build under Wine): rerun with")
log("--allow-non-windows. Requires wine + a Windows Python in $PATH")
log("via wine python.exe — fragile and not exercised by CI.")
return 1
BUILD_DIR.mkdir(parents=True, exist_ok=True)
DOWNLOADS.mkdir(parents=True, exist_ok=True)
STAGING.mkdir(parents=True, exist_ok=True)
python_dir = stage_embedded_python()
if not args.skip_pip:
install_requirements(python_dir)
if args.skip_frontend:
frontend_dist = REPO_ROOT / "frontend" / "dist"
if not frontend_dist.exists():
raise RuntimeError("--skip-frontend given but frontend/dist/ doesn't exist")
else:
frontend_dist = build_frontend()
stage_backend(frontend_dist)
stage_nssm()
stage_ffmpeg()
stage_service_scripts()
write_version_file()
log("")
log("=" * 60)
log("Staging complete.")
log(f"Staged tree: {STAGING}")
log("")
log("Next: compile the Inno Setup script:")
log(' "C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe" bambuddy.iss')
log("")
log(f"Installer will be written to: {BUILD_DIR / 'output'}")
log("=" * 60)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,75 @@
@echo off
REM Register Bambuddy as a Windows service via NSSM.
REM
REM Called from Inno Setup's [Run] section. Arguments:
REM %1 = install dir (e.g. C:\Program Files\Bambuddy)
REM %2 = data dir (e.g. C:\ProgramData\Bambuddy)
REM %3 = port (e.g. 8000)
REM
REM If the service already exists (re-install / upgrade), remove and
REM re-create it so config changes from this build apply.
setlocal
set "INSTALL_DIR=%~1"
set "DATA_ROOT=%~2"
set "PORT=%~3"
set "NSSM=%INSTALL_DIR%\bin\nssm.exe"
set "PYTHON=%INSTALL_DIR%\python\python.exe"
set "APP_DIR=%INSTALL_DIR%\app"
set "BIN_DIR=%INSTALL_DIR%\bin"
set "DATA_DIR=%DATA_ROOT%\data"
set "LOG_DIR=%DATA_ROOT%\logs"
REM Stop and remove any previous registration. Errors are non-fatal —
REM "service not found" returns non-zero and we want to proceed.
"%NSSM%" stop Bambuddy 2>nul
"%NSSM%" remove Bambuddy confirm 2>nul
REM Register the service. NSSM wraps uvicorn so Windows treats it as a
REM proper service (autostart, recovery, supervised restart).
"%NSSM%" install Bambuddy "%PYTHON%" "-m uvicorn backend.app.main:app --host 0.0.0.0 --port %PORT%"
if errorlevel 1 (
echo [install-service] nssm install failed
exit /b 1
)
REM Service configuration
"%NSSM%" set Bambuddy AppDirectory "%APP_DIR%"
"%NSSM%" set Bambuddy DisplayName "Bambuddy"
"%NSSM%" set Bambuddy Description "Bambuddy — local-first Bambu Lab printer manager"
"%NSSM%" set Bambuddy Start SERVICE_AUTO_START
REM Environment: point DATA_DIR + LOG_DIR at ProgramData, prepend our
REM bin/ to PATH so ffmpeg/ffprobe are found by the shutil.which() lookup
REM in backend/app/services/layer_timelapse.py.
"%NSSM%" set Bambuddy AppEnvironmentExtra ^
"DATA_DIR=%DATA_DIR%" ^
"LOG_DIR=%LOG_DIR%" ^
"PORT=%PORT%" ^
"PATH=%BIN_DIR%;%PATH%"
REM Stdout / stderr capture. Rotate at 10MB.
"%NSSM%" set Bambuddy AppStdout "%LOG_DIR%\service-stdout.log"
"%NSSM%" set Bambuddy AppStderr "%LOG_DIR%\service-stderr.log"
"%NSSM%" set Bambuddy AppRotateFiles 1
"%NSSM%" set Bambuddy AppRotateOnline 1
"%NSSM%" set Bambuddy AppRotateBytes 10485760
REM Run as LocalSystem (default). Required for binding 322/990/8883 if
REM the user later enables the Virtual Printer feature. Most non-VP
REM workloads would work as a less-privileged account, but service
REM identity changes are disruptive — pick the broader one once.
REM Start the service. If it fails to start, NSSM exits non-zero and
REM Inno Setup will surface this to the user.
"%NSSM%" start Bambuddy
if errorlevel 1 (
echo [install-service] nssm start failed — check %LOG_DIR%\service-stderr.log
exit /b 1
)
echo [install-service] Bambuddy service registered and started on port %PORT%
endlocal
exit /b 0
@@ -0,0 +1,22 @@
@echo off
REM Stop and deregister the Bambuddy Windows service.
REM
REM Called from Inno Setup's [UninstallRun] section. Argument:
REM %1 = install dir (e.g. C:\Program Files\Bambuddy)
setlocal
set "INSTALL_DIR=%~1"
set "NSSM=%INSTALL_DIR%\bin\nssm.exe"
REM Stop is best-effort — if the service is already stopped, NSSM
REM returns non-zero and we want to proceed to the remove step.
"%NSSM%" stop Bambuddy 2>nul
REM Remove the service registration. confirm flag skips the
REM interactive prompt.
"%NSSM%" remove Bambuddy confirm 2>nul
echo [uninstall-service] Bambuddy service deregistered
endlocal
exit /b 0