mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-01 11:47:46 +02:00
feat(windows-installer): native .exe installer pipeline
Brings the Windows installer work from dev to main without merging
the rest of the 0.2.5b1 release content. Squashes 12 commits from
dev (8711c54e..7bb11df2) into a single net-effect commit on main.
Includes:
- installers/windows/ — Inno Setup .iss script, build.py, vendored
NSSM 2.24, bambuddy.ico (multi-resolution app icon), service
install/uninstall .bat files, build pipeline README
- backend/app/services/network_utils.py — Windows psutil branch so
the VP bind-IP dropdown enumerates interfaces; Linux/macOS path
unchanged
- .github/workflows/windows-installer.yml — reconciles main's
kludge-pushed copy with dev's accumulated changes (NSSM
vendoring, version-from-tag, unversioned alias step, etc.)
CHANGELOG and README entries for the Windows installer stay on
dev — they reference unreleased 0.2.5b1 release notes that aren't
on main yet.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Build artifacts — large and reproducible from build.py
|
||||
build/
|
||||
@@ -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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
@@ -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;
|
||||
@@ -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 ``<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)
|
||||
# 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()
|
||||
if not args.skip_pip:
|
||||
install_requirements(python_dir)
|
||||
|
||||
if args.skip_frontend:
|
||||
frontend_dist = REPO_ROOT / "frontend" / "dist"
|
||||
if not frontend_dist.exists():
|
||||
raise RuntimeError("--skip-frontend given but frontend/dist/ doesn't exist")
|
||||
else:
|
||||
frontend_dist = build_frontend()
|
||||
|
||||
stage_backend(frontend_dist)
|
||||
stage_nssm()
|
||||
stage_ffmpeg()
|
||||
stage_service_scripts()
|
||||
write_version_file()
|
||||
|
||||
log("")
|
||||
log("=" * 60)
|
||||
log("Staging complete.")
|
||||
log(f"Staged tree: {STAGING}")
|
||||
log("")
|
||||
log("Next: compile the Inno Setup script:")
|
||||
log(' "C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe" bambuddy.iss')
|
||||
log("")
|
||||
log(f"Installer will be written to: {BUILD_DIR / 'output'}")
|
||||
log("=" * 60)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,75 @@
|
||||
@echo off
|
||||
REM Register Bambuddy as a Windows service via NSSM.
|
||||
REM
|
||||
REM Called from Inno Setup's [Run] section. Arguments:
|
||||
REM %1 = install dir (e.g. C:\Program Files\Bambuddy)
|
||||
REM %2 = data dir (e.g. C:\ProgramData\Bambuddy)
|
||||
REM %3 = port (e.g. 8000)
|
||||
REM
|
||||
REM If the service already exists (re-install / upgrade), remove and
|
||||
REM re-create it so config changes from this build apply.
|
||||
|
||||
setlocal
|
||||
|
||||
set "INSTALL_DIR=%~1"
|
||||
set "DATA_ROOT=%~2"
|
||||
set "PORT=%~3"
|
||||
|
||||
set "NSSM=%INSTALL_DIR%\bin\nssm.exe"
|
||||
set "PYTHON=%INSTALL_DIR%\python\python.exe"
|
||||
set "APP_DIR=%INSTALL_DIR%\app"
|
||||
set "BIN_DIR=%INSTALL_DIR%\bin"
|
||||
set "DATA_DIR=%DATA_ROOT%\data"
|
||||
set "LOG_DIR=%DATA_ROOT%\logs"
|
||||
|
||||
REM Stop and remove any previous registration. Errors are non-fatal —
|
||||
REM "service not found" returns non-zero and we want to proceed.
|
||||
"%NSSM%" stop Bambuddy 2>nul
|
||||
"%NSSM%" remove Bambuddy confirm 2>nul
|
||||
|
||||
REM Register the service. NSSM wraps uvicorn so Windows treats it as a
|
||||
REM proper service (autostart, recovery, supervised restart).
|
||||
"%NSSM%" install Bambuddy "%PYTHON%" "-m uvicorn backend.app.main:app --host 0.0.0.0 --port %PORT%"
|
||||
if errorlevel 1 (
|
||||
echo [install-service] nssm install failed
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Service configuration
|
||||
"%NSSM%" set Bambuddy AppDirectory "%APP_DIR%"
|
||||
"%NSSM%" set Bambuddy DisplayName "Bambuddy"
|
||||
"%NSSM%" set Bambuddy Description "Bambuddy — local-first Bambu Lab printer manager"
|
||||
"%NSSM%" set Bambuddy Start SERVICE_AUTO_START
|
||||
|
||||
REM Environment: point DATA_DIR + LOG_DIR at ProgramData, prepend our
|
||||
REM bin/ to PATH so ffmpeg/ffprobe are found by the shutil.which() lookup
|
||||
REM in backend/app/services/layer_timelapse.py.
|
||||
"%NSSM%" set Bambuddy AppEnvironmentExtra ^
|
||||
"DATA_DIR=%DATA_DIR%" ^
|
||||
"LOG_DIR=%LOG_DIR%" ^
|
||||
"PORT=%PORT%" ^
|
||||
"PATH=%BIN_DIR%;%PATH%"
|
||||
|
||||
REM Stdout / stderr capture. Rotate at 10MB.
|
||||
"%NSSM%" set Bambuddy AppStdout "%LOG_DIR%\service-stdout.log"
|
||||
"%NSSM%" set Bambuddy AppStderr "%LOG_DIR%\service-stderr.log"
|
||||
"%NSSM%" set Bambuddy AppRotateFiles 1
|
||||
"%NSSM%" set Bambuddy AppRotateOnline 1
|
||||
"%NSSM%" set Bambuddy AppRotateBytes 10485760
|
||||
|
||||
REM Run as LocalSystem (default). Required for binding 322/990/8883 if
|
||||
REM the user later enables the Virtual Printer feature. Most non-VP
|
||||
REM workloads would work as a less-privileged account, but service
|
||||
REM identity changes are disruptive — pick the broader one once.
|
||||
|
||||
REM Start the service. If it fails to start, NSSM exits non-zero and
|
||||
REM Inno Setup will surface this to the user.
|
||||
"%NSSM%" start Bambuddy
|
||||
if errorlevel 1 (
|
||||
echo [install-service] nssm start failed — check %LOG_DIR%\service-stderr.log
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [install-service] Bambuddy service registered and started on port %PORT%
|
||||
endlocal
|
||||
exit /b 0
|
||||
@@ -0,0 +1,22 @@
|
||||
@echo off
|
||||
REM Stop and deregister the Bambuddy Windows service.
|
||||
REM
|
||||
REM Called from Inno Setup's [UninstallRun] section. Argument:
|
||||
REM %1 = install dir (e.g. C:\Program Files\Bambuddy)
|
||||
|
||||
setlocal
|
||||
|
||||
set "INSTALL_DIR=%~1"
|
||||
set "NSSM=%INSTALL_DIR%\bin\nssm.exe"
|
||||
|
||||
REM Stop is best-effort — if the service is already stopped, NSSM
|
||||
REM returns non-zero and we want to proceed to the remove step.
|
||||
"%NSSM%" stop Bambuddy 2>nul
|
||||
|
||||
REM Remove the service registration. confirm flag skips the
|
||||
REM interactive prompt.
|
||||
"%NSSM%" remove Bambuddy confirm 2>nul
|
||||
|
||||
echo [uninstall-service] Bambuddy service deregistered
|
||||
endlocal
|
||||
exit /b 0
|
||||
Vendored
BIN
Binary file not shown.
Reference in New Issue
Block a user