diff --git a/.github/workflows/windows-installer.yml b/.github/workflows/windows-installer.yml new file mode 100644 index 000000000..821a51d93 --- /dev/null +++ b/.github/workflows/windows-installer.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 115a460c9..b0e51f87a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/installers/windows/.gitignore b/installers/windows/.gitignore new file mode 100644 index 000000000..108267735 --- /dev/null +++ b/installers/windows/.gitignore @@ -0,0 +1,2 @@ +# Build artifacts — large and reproducible from build.py +build/ diff --git a/installers/windows/README.md b/installers/windows/README.md new file mode 100644 index 000000000..efaab6949 --- /dev/null +++ b/installers/windows/README.md @@ -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. diff --git a/installers/windows/bambuddy.iss b/installers/windows/bambuddy.iss new file mode 100644 index 000000000..9fd74f6c6 --- /dev/null +++ b/installers/windows/bambuddy.iss @@ -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; diff --git a/installers/windows/build.py b/installers/windows/build.py new file mode 100644 index 000000000..f0a17583d --- /dev/null +++ b/installers/windows/build.py @@ -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()) diff --git a/installers/windows/service/install-service.bat b/installers/windows/service/install-service.bat new file mode 100644 index 000000000..eb15f050a --- /dev/null +++ b/installers/windows/service/install-service.bat @@ -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 diff --git a/installers/windows/service/uninstall-service.bat b/installers/windows/service/uninstall-service.bat new file mode 100644 index 000000000..86b364bc7 --- /dev/null +++ b/installers/windows/service/uninstall-service.bat @@ -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