diff --git a/.github/workflows/windows-installer.yml b/.github/workflows/windows-installer.yml index 1d5888d8f..6d1036783 100644 --- a/.github/workflows/windows-installer.yml +++ b/.github/workflows/windows-installer.yml @@ -57,6 +57,29 @@ jobs: & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" bambuddy.iss shell: pwsh + # Stable + beta tag releases (e.g. v0.2.5b1, v0.3.0) get an unversioned + # copy alongside the versioned filename so external surfaces (website, + # wiki, newsletters) can link to a stable URL that survives version + # bumps: + # + # https://github.com/maziggy/bambuddy/releases/latest/download/bambuddy-windows-x64-setup.exe + # + # GitHub's `latest` redirect excludes prereleases, so this URL always + # points at whatever was released as a full release. Daily prereleases + # are excluded from the alias because (a) the unversioned name would be + # semantically confusing next to the date-stamped versioned name on a + # daily prerelease page, and (b) there's no stable "latest daily" URL + # anyway (`latest` skips prereleases), so the alias adds no value there. + - name: Create unversioned alias (non-daily tags only) + if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-daily.') + shell: pwsh + working-directory: installers/windows/build/output + run: | + $versioned = Get-ChildItem -Filter "bambuddy-*-windows-x64-setup.exe" | Select-Object -First 1 + if (-not $versioned) { throw "no versioned installer .exe found" } + Copy-Item $versioned.FullName "bambuddy-windows-x64-setup.exe" + Write-Host "alias: bambuddy-windows-x64-setup.exe -> $($versioned.Name)" + - name: Upload installer artifact uses: actions/upload-artifact@v4 with: diff --git a/backend/app/services/network_utils.py b/backend/app/services/network_utils.py index 5afb84148..6ef9293cf 100644 --- a/backend/app/services/network_utils.py +++ b/backend/app/services/network_utils.py @@ -7,10 +7,14 @@ import shutil import socket import struct import subprocess +import sys logger = logging.getLogger(__name__) -# Interfaces to exclude from selection +# Interfaces to exclude from selection (Linux only — Windows adapter names +# don't follow these prefixes and there's no equivalent uniform Windows +# exclude list worth hard-coding; the psutil path filters on address class +# (loopback, link-local) and interface up-state instead). EXCLUDED_INTERFACE_PREFIXES = ("lo", "docker", "br-", "veth", "virbr") # Resolve full path to `ip` command (may not be in PATH for service users) @@ -22,12 +26,86 @@ def _is_excluded(name: str) -> bool: return any(name.startswith(prefix) for prefix in EXCLUDED_INTERFACE_PREFIXES) +def _get_network_interfaces_psutil() -> list[dict]: + """Windows path: enumerate interfaces via psutil. + + fcntl + ioctl is Linux-only, and the ``ip`` command isn't available + on Windows either, so both Linux code paths return empty here. psutil + is already a Bambuddy dep (``psutil>=6.0.0``) and gives us cross- + platform name + IPv4 + netmask in one call. + + Filters: IPv4 only (matches the Linux path), skip loopback and + link-local (169.254.0.0/16), skip interfaces psutil reports as down. + No name-based exclusion — users on Windows may legitimately want to + bind a VP to a Hyper-V / WSL / Tailscale virtual adapter. + """ + try: + import psutil + except ImportError: + logger.warning("psutil not available, interface detection unavailable on this platform") + return [] + + interfaces = [] + try: + addrs_by_iface = psutil.net_if_addrs() + stats_by_iface = psutil.net_if_stats() + except Exception as e: + logger.error("psutil failed to enumerate interfaces: %s", e) + return [] + + for name, addrs in addrs_by_iface.items(): + stats = stats_by_iface.get(name) + if stats is not None and not stats.isup: + continue + + for addr in addrs: + if addr.family != socket.AF_INET: + continue + ip = addr.address + netmask = addr.netmask + if not ip or not netmask: + continue + + try: + ip_obj = ipaddress.IPv4Address(ip) + except ValueError: + continue + if ip_obj.is_loopback or ip_obj.is_link_local: + continue + + try: + network = ipaddress.IPv4Network(f"{ip}/{netmask}", strict=False) + except ValueError: + continue + + interfaces.append( + { + "name": name, + "ip": ip, + "netmask": netmask, + "subnet": str(network), + } + ) + # First IPv4 per interface is enough; matches Linux ioctl which + # returns only the primary IP (aliases land via get_all_interface_ips + # on Linux, which has no Windows analogue worth replicating). + break + + return interfaces + + def get_network_interfaces() -> list[dict]: """Get all network interfaces with their IPs and subnets. Returns: List of dicts with name, ip, netmask, subnet, broadcast """ + # Windows has no fcntl and no `ip` binary; the Linux ioctl path below + # raises ImportError on import fcntl. Route to the psutil-based path + # instead. The Linux path stays as-is for behavioural parity. + if sys.platform == "win32": + return _get_network_interfaces_psutil() + interfaces = [] try: 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.ico b/installers/windows/bambuddy.ico new file mode 100644 index 000000000..93f2dc013 Binary files /dev/null and b/installers/windows/bambuddy.ico differ diff --git a/installers/windows/bambuddy.iss b/installers/windows/bambuddy.iss new file mode 100644 index 000000000..e2c325655 --- /dev/null +++ b/installers/windows/bambuddy.iss @@ -0,0 +1,172 @@ +; 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= +; Bambuddy branding — bambuddy.ico is a multi-resolution .ico (16/32/48/ +; 64/128/256) generated from frontend/public/img/favicon.png; lives next +; to this .iss so the SourcePath-relative reference works during compile +; and the [Files] entry stages it into {app} for Add/Remove Programs. +SetupIconFile=bambuddy.ico +UninstallDisplayIcon={app}\bambuddy.ico +; 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 +; App icon — used by UninstallDisplayIcon (Add/Remove Programs) and the +; Start Menu / desktop shortcuts. Lives at the install root so the +; UninstallDisplayIcon path stays stable when the [Files] tree changes. +Source: "bambuddy.ico"; 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}\bambuddy.ico" +Name: "{group}\Bambuddy Logs"; Filename: "{commonappdata}\Bambuddy\logs" +Name: "{group}\Uninstall Bambuddy"; Filename: "{uninstallexe}" +Name: "{commondesktop}\Bambuddy"; Filename: "http://localhost:{#DefaultPort}"; IconFilename: "{app}\bambuddy.ico"; 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. RunOnceId makes the +; entry run-once per uninstall pass (Inno Setup default is to re-run on +; every pass, which can fire multiple times during upgrade flows). +Filename: "{app}\service\uninstall-service.bat"; Parameters: """{app}"""; Flags: runhidden waituntilterminated; RunOnceId: "StopBambuddyService" + +; 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; RunOnceId: "RemoveFirewallRule" + +[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] + +// Stop the Bambuddy service BEFORE the [Files] section copies anything, +// so file locks on python.exe / .pyd / nssm.exe release in time for the +// overwrite. Without this, upgrading over a running install fails with +// "permission denied" on every file the service has open. +// +// On a fresh install {app}\bin\nssm.exe doesn't exist yet — FileExists +// guards that path so the hook is a no-op for first-time installers. +// The Sleep gives Windows a beat to finalize the python.exe unload +// before the [Files] step starts grabbing exclusive handles. +// +// The install-service.bat in [Run] does `nssm remove ... confirm` plus +// a fresh `nssm install`, so even if we leave the old service entry in +// place here, the post-install step re-registers it cleanly. +function PrepareToInstall(var NeedsRestart: Boolean): String; +var + ResultCode: Integer; + NssmPath: string; +begin + Result := ''; + NeedsRestart := False; + + NssmPath := ExpandConstant('{app}\bin\nssm.exe'); + if FileExists(NssmPath) then + begin + Log('Stopping Bambuddy service before file copy...'); + Exec(NssmPath, 'stop Bambuddy', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + // ResultCode 0 == stopped; non-zero is fine too (already stopped / + // service not registered). The lock we care about is python.exe's, + // and it's released the moment the process exits. + Sleep(1500); + end; +end; + +// 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..0167b9aea --- /dev/null +++ b/installers/windows/build.py @@ -0,0 +1,403 @@ +"""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" + + +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 ``/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) + # 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[-daily.]`` + 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() + 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 diff --git a/installers/windows/vendor/nssm.exe b/installers/windows/vendor/nssm.exe new file mode 100644 index 000000000..6ccfe3cfb Binary files /dev/null and b/installers/windows/vendor/nssm.exe differ