| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373 |
- """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,
- )
- # 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 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", "._*"),
- )
- # gcode_viewer/ is a vendored 3D-preview iframe served via explicit
- # routes in main.py (looked up via static_dir.parent / "gcode_viewer").
- # In the staged layout STAGING/app/static/'s sibling is STAGING/app/,
- # so place the directory next to static/ to match runtime resolution.
- gcode_viewer_src = REPO_ROOT / "gcode_viewer"
- if gcode_viewer_src.exists():
- log("staging gcode_viewer/")
- shutil.copytree(
- gcode_viewer_src,
- app / "gcode_viewer",
- ignore=shutil.ignore_patterns(".DS_Store", "._*"),
- )
- 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 ``APP_VERSION`` from ``backend/app/core/config.py`` — that's the
- canonical version used by every other surface in Bambuddy (the FastAPI
- OpenAPI title, /system info, the support bundle, the spoolbuddy update
- check). pyproject.toml has its own stale ``version = "0.1.5"`` that
- isn't kept in sync; reading it would ship a wrong-versioned installer.
- """
- version = "0.0.0+dev"
- config_py = REPO_ROOT / "backend" / "app" / "core" / "config.py"
- if config_py.exists():
- for raw in config_py.read_text().splitlines():
- stripped = raw.strip()
- if stripped.startswith("APP_VERSION"):
- # APP_VERSION = "0.2.5b1" -> 0.2.5b1
- version = stripped.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())
|