mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-29 18:51:43 +02:00
Sliced files previewed through a vendored copy of PrettyGCode in an iframe. It drew each move as a screen-space line -- a line has no thickness in the scene, so it cannot occlude the layer behind it, which is why prints came out stringy and shimmered where layers crossed. Being a separate app in a frame, it could be neither themed nor translated, and carried its own machinery for detecting a proxy refusing the embed. Now built on libvgcode, the renderer OrcaSlicer draws its own preview with, vendored from three-slicer (AGPL, same as us). It takes the THREE namespace as an argument and imports nothing, so it runs on our 0.181 rather than the 0.160 its package pins. The parser is ours; upstream renders its own kernel's output and ships no G-code parser at all. Two things it has to get right, both found by checking a real plate rather than assuming: - BambuStudio does not use the OrcaSlicer/PrusaSlicer annotations. It writes "; FEATURE:", "; LINE_WIDTH:", "; CHANGE_LAYER" and "; Z_HEIGHT:", not ";TYPE:", ";WIDTH:" and ";LAYER_CHANGE". Reading only the latter showed a 52-layer print as 23,165 layers in one colour, because with no layer marker recognised every travel Z-hop split a layer and every segment took the fallback feature. - It emits a tenth of its moves as G2/G3 arcs -- 706 extruding ones in a single plate. Ignoring them punched holes through curved walls and tree supports. Arcs with no X/Y are the helical travel lift and lay down nothing, so they interpolate as travels. Four colour modes: filament (default, from the AMS slots the file was sliced with), feature, layer height, line width. Speed, fan and temperature are deliberately absent -- upstream derives those from settings rather than the toolpath, and guesses dressed as measurements are worse than an honest omission. The parser now carries the data to do them properly later. Legend entries are switches. Hiding removes the records before the mesh is built rather than recolouring them: the shader packs colour into a single float with no alpha, so there is no transparent to set, and removal is the useful behaviour anyway -- a hidden support stops occluding what it covered. The scene is built once and only the toolpath rebuilds. Doing otherwise constructed a new WebGLRenderer on every render, because the buildVolume default is an object literal and so a fresh identity each time; browsers cap live WebGL contexts and drop the oldest, which blanked the canvas after a few interactions. utils/framing.ts goes with the iframe, along with six now-orphaned strings in all 13 locales. src/lib/vendor is excluded from eslint -- acting on findings in vendored code makes it impossible to re-copy on the next upstream release.
433 lines
16 KiB
Python
433 lines
16 KiB
Python
"""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 os
|
|
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 (no new release since 2014).
|
|
# Vendored under installers/windows/vendor/nssm.exe rather than fetched
|
|
# at build time — nssm.cc has flaked with 503s mid-CI-run before, and
|
|
# pinning to a checked-in binary makes builds reproducible and lets us
|
|
# inspect the binary in PRs if it ever needs updating. SHA-256:
|
|
# f689ee9af94b00e9e3f0bb072b34caaf207f32dcb4f5782fc9ca351df9a06c97
|
|
NSSM_VERSION = "2.24"
|
|
|
|
# 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"
|
|
|
|
# C++ runtime DLLs the embeddable distribution does NOT ship. The python.org
|
|
# embeddable zip includes vcruntime140.dll but not vcruntime140_1.dll or
|
|
# msvcp140.dll. python313.dll is pure C and only needs vcruntime140.dll, so
|
|
# python.exe starts fine — but greenlet's _greenlet.pyd is C++ and needs
|
|
# vcruntime140_1.dll (table-based exception handling). On a fresh Windows box
|
|
# that never had the VC++ 2015-2022 redistributable installed, loading greenlet
|
|
# fails with "DLL load failed ... The specified module could not be found",
|
|
# SQLAlchemy's async engine can't start, init_db() raises, and the app never
|
|
# binds its port — the service shows "running" but the dashboard refuses the
|
|
# connection (issue #2474). These runtime DLLs are redistributable, so we ship
|
|
# them app-locally next to python.exe (where vcruntime140.dll already lives).
|
|
VCRUNTIME_DLLS = ("vcruntime140_1.dll", "msvcp140.dll")
|
|
|
|
|
|
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,
|
|
)
|
|
|
|
# Install setuptools + wheel. The embedded distribution ships without
|
|
# them, and get-pip.py installs only pip — but pip needs
|
|
# ``setuptools.build_meta`` (PEP 517 backend) to build any source-only
|
|
# package. Bambuddy's requirements.txt hits this with pyftpdlib 2.2.0
|
|
# which is sdist-only on PyPI; other source-only packages would fail
|
|
# the same way without this step.
|
|
log("installing setuptools + wheel for PEP 517 builds")
|
|
subprocess.run(
|
|
[
|
|
str(target / "python.exe"),
|
|
"-m",
|
|
"pip",
|
|
"install",
|
|
"--no-warn-script-location",
|
|
"setuptools",
|
|
"wheel",
|
|
],
|
|
check=True,
|
|
)
|
|
|
|
return target
|
|
|
|
|
|
def stage_vcruntime(python_dir: Path) -> None:
|
|
"""Ship the C++ runtime DLLs the embeddable distribution omits.
|
|
|
|
Placed next to python.exe so the extension-module loader (which searches the
|
|
interpreter's own directory) finds them without a redistributable install on
|
|
the target machine. Prefers vendored copies under installers/windows/vendor/
|
|
for reproducibility; falls back to the build runner's System32, where the
|
|
redistributable runtime lives. Fails loudly if neither source has them, so a
|
|
misconfigured build machine is caught here instead of by end users.
|
|
"""
|
|
vendor = INSTALLER_DIR / "vendor"
|
|
system32 = Path(os.environ.get("SYSTEMROOT", r"C:\Windows")) / "System32"
|
|
for dll in VCRUNTIME_DLLS:
|
|
dst = python_dir / dll
|
|
if dst.exists():
|
|
log(f"{dll} already present in embedded Python")
|
|
continue
|
|
src = vendor / dll if (vendor / dll).exists() else system32 / dll
|
|
if not src.exists():
|
|
raise RuntimeError(
|
|
f"required C++ runtime DLL not found: looked in {vendor} and {system32} "
|
|
f"for {dll}. Install the Microsoft Visual C++ 2015-2022 Redistributable "
|
|
f"(x64) on the build machine, or vendor {dll} under installers/windows/vendor/."
|
|
)
|
|
log(f"staging {dll} from {src}")
|
|
shutil.copy(src, dst)
|
|
|
|
|
|
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 build output path.
|
|
|
|
Vite is configured with ``outDir: '../static'`` (see
|
|
``frontend/vite.config.ts``), so the bundle lands at ``<repo>/static/``
|
|
— NOT ``frontend/dist/``. The path matches the runtime expectation in
|
|
``backend/app/core/config.py`` (``static_dir = _app_dir / "static"``).
|
|
"""
|
|
frontend = REPO_ROOT / "frontend"
|
|
dist = REPO_ROOT / "static"
|
|
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.
|
|
# Strip macOS metadata files (.DS_Store, ._.*) that the dev box leaks
|
|
# in; they'd just bloat the installer and never be served anyway.
|
|
log("staging frontend bundle")
|
|
shutil.copytree(
|
|
frontend_dist,
|
|
app / "static",
|
|
ignore=shutil.ignore_patterns(".DS_Store", "._*"),
|
|
)
|
|
|
|
|
|
def stage_nssm() -> None:
|
|
target = STAGING / "bin"
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
# Vendored binary — no network fetch at build time
|
|
src = INSTALLER_DIR / "vendor" / "nssm.exe"
|
|
if not src.exists():
|
|
raise RuntimeError(f"vendored NSSM binary missing at {src} — was it committed?")
|
|
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 _read_app_version() -> str:
|
|
"""Read APP_VERSION from backend/app/core/config.py (the canonical
|
|
source used by every other Bambuddy surface — FastAPI OpenAPI title,
|
|
/system info, support bundles, spoolbuddy update check).
|
|
"""
|
|
config_py = REPO_ROOT / "backend" / "app" / "core" / "config.py"
|
|
if not config_py.exists():
|
|
return "0.0.0+dev"
|
|
for raw in config_py.read_text().splitlines():
|
|
stripped = raw.strip()
|
|
if stripped.startswith("APP_VERSION"):
|
|
# APP_VERSION = "0.2.5b1" -> 0.2.5b1
|
|
return stripped.split("=", 1)[1].strip().strip('"').strip("'")
|
|
return "0.0.0+dev"
|
|
|
|
|
|
def _resolve_installer_version() -> str:
|
|
"""Decide what version string the installer carries.
|
|
|
|
Priority:
|
|
1. ``GITHUB_REF`` env var when set to a tag (e.g.
|
|
``refs/tags/v0.2.5b1-daily.20260610``) — the daily-beta and stable
|
|
publish scripts both push tags in the ``v<APP_VERSION>[-daily.<date>]``
|
|
shape, and we want the installer filename + Inno Setup AppVersion
|
|
to match the GitHub release exactly so dailies stay distinguishable
|
|
from each other and from the eventual stable.
|
|
2. ``APP_VERSION`` from config.py for manual workflow_dispatch runs
|
|
(no tag) and for local builds.
|
|
|
|
Strips the leading ``v`` from tags so the installer filename is
|
|
``bambuddy-0.2.5b1-daily.20260610-windows-x64-setup.exe``, not
|
|
``bambuddy-v0.2.5b1-...``.
|
|
"""
|
|
ref = os.environ.get("GITHUB_REF", "")
|
|
if ref.startswith("refs/tags/"):
|
|
tag = ref.removeprefix("refs/tags/")
|
|
if tag.startswith("v"):
|
|
tag = tag[1:]
|
|
return tag or _read_app_version()
|
|
return _read_app_version()
|
|
|
|
|
|
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.
|
|
"""
|
|
version = _resolve_installer_version()
|
|
(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()
|
|
stage_vcruntime(python_dir)
|
|
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())
|