Просмотр исходного кода

feat(installer): scaffold Windows installer build pipeline

  Lays down the Inno Setup + embedded Python pipeline for producing a
  self-contained Bambuddy Windows installer .exe. The installer ships
  an embedded Python 3.13, the pre-built React bundle, NSSM (service
  supervisor) and ffmpeg — no host Python or Node required on the
  target machine.

  Architecture:
  - Install: C:\Program Files\Bambuddy (admin install, one-time UAC)
  - Data:    C:\ProgramData\Bambuddy\data (preserved on uninstall)
  - Service: registered via NSSM, runs as LocalSystem, autostart on boot
  - UI:      browser at http://localhost:8000 (Start Menu shortcut)

  Files:
  - installers/windows/build.py            stages embedded Python + deps,
                                           frontend bundle, NSSM, ffmpeg
  - installers/windows/bambuddy.iss        Inno Setup compiler script
  - installers/windows/service/*.bat       NSSM register/deregister
  - .github/workflows/windows-installer.yml CI build on tag push + manual
                                           dispatch, uploads .exe artifact

  build.py hard-fails on non-Windows hosts; Wine cross-build is an
  unsupported escape hatch behind --allow-non-windows. v1 ships unsigned
  (SmartScreen warns on first run) — production signing will be wired up
  via SignPath OSS once the application is approved.

  See installers/windows/README.md for build prerequisites and the
  embedded-Python ._pth gotchas.
maziggy 2 месяцев назад
Родитель
Сommit
8711c54ec3

+ 66 - 0
.github/workflows/windows-installer.yml

@@ -0,0 +1,66 @@
+name: Windows Installer
+
+# Build the Windows installer .exe.
+#
+# Triggers:
+#   - Tag push matching v* (release builds, uploaded as a release asset)
+#   - Manual dispatch (for testing the build pipeline)
+#
+# The installer is unsigned until SignPath OSS approval lands. Once it
+# does, add the SignPath GitHub Action between the ISCC step and the
+# upload step.
+
+on:
+  push:
+    tags:
+      - 'v*'
+  workflow_dispatch:
+
+jobs:
+  build:
+    runs-on: windows-latest
+    timeout-minutes: 30
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Setup Python
+        uses: actions/setup-python@v5
+        with:
+          python-version: '3.13'
+
+      - name: Setup Node.js
+        uses: actions/setup-node@v4
+        with:
+          node-version: '22'
+
+      - name: Install Inno Setup
+        run: |
+          choco install innosetup --version=6.2.2 --no-progress -y
+        shell: pwsh
+
+      - name: Stage installer artifacts
+        working-directory: installers/windows
+        run: python build.py
+        shell: pwsh
+
+      - name: Compile installer (ISCC)
+        working-directory: installers/windows
+        run: |
+          & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" bambuddy.iss
+        shell: pwsh
+
+      - name: Upload installer artifact
+        uses: actions/upload-artifact@v4
+        with:
+          name: bambuddy-windows-installer
+          path: installers/windows/build/output/*.exe
+          if-no-files-found: error
+
+      - name: Attach installer to release
+        if: startsWith(github.ref, 'refs/tags/v')
+        uses: softprops/action-gh-release@v2
+        with:
+          files: installers/windows/build/output/*.exe
+          fail_on_unmatched_files: true

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 2 - 0
installers/windows/.gitignore

@@ -0,0 +1,2 @@
+# Build artifacts — large and reproducible from build.py
+build/

+ 84 - 0
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.

+ 128 - 0
installers/windows/bambuddy.iss

@@ -0,0 +1,128 @@
+; Bambuddy Windows Installer — Inno Setup script
+;
+; Builds a self-contained installer that lays down:
+;   - embedded Python 3.13 + pre-installed venv
+;   - backend source + pre-built frontend bundle
+;   - NSSM + ffmpeg under bin/
+;   - a Windows service running as LocalSystem
+;
+; Build prerequisites: run installers/windows/build.py first to stage
+; the build/staging/ tree, then compile this file with ISCC.exe.
+;
+; See installers/windows/README.md for the full pipeline.
+
+#define MyAppName "Bambuddy"
+#define MyAppPublisher "Martin Ziegler"
+#define MyAppURL "https://bambuddy.cool"
+#define MyAppExeName "bambuddy.exe"
+#define ServiceName "Bambuddy"
+#define DefaultPort "8000"
+
+; Version is stamped by build.py into build\staging\version.iss as a
+; #define directive. Falls back to a placeholder if you ran ISCC without
+; running build.py first (don't ship that build).
+#ifexist "build\staging\version.iss"
+  #include "build\staging\version.iss"
+#else
+  #define MyAppVersion "0.0.0+dev"
+#endif
+
+[Setup]
+AppId={{8C9C9E1A-7C5A-4F2A-9F1B-BAMBUDDY00001}}
+AppName={#MyAppName}
+AppVersion={#MyAppVersion}
+AppPublisher={#MyAppPublisher}
+AppPublisherURL={#MyAppURL}
+AppSupportURL={#MyAppURL}
+AppUpdatesURL={#MyAppURL}
+DefaultDirName={autopf}\Bambuddy
+DefaultGroupName={#MyAppName}
+DisableProgramGroupPage=yes
+LicenseFile=..\..\LICENSE
+OutputDir=build\output
+OutputBaseFilename=bambuddy-{#MyAppVersion}-windows-x64-setup
+Compression=lzma
+SolidCompression=yes
+WizardStyle=modern
+ArchitecturesAllowed=x64compatible
+ArchitecturesInstallIn64BitMode=x64compatible
+; Admin required: we register a Windows service and write to ProgramData
+PrivilegesRequired=admin
+PrivilegesRequiredOverridesAllowed=
+UninstallDisplayIcon={app}\bin\nssm.exe
+SetupIconFile=
+; Don't allow installing to a network drive — service won't start cleanly
+DisableDirPage=no
+DisableReadyPage=no
+ChangesEnvironment=no
+CloseApplications=no
+
+[Languages]
+Name: "english"; MessagesFile: "compiler:Default.isl"
+Name: "german"; MessagesFile: "compiler:Languages\German.isl"
+
+[Tasks]
+Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional shortcuts:"; Flags: unchecked
+Name: "firewallrule"; Description: "Add Windows Firewall rule for Bambuddy (port {#DefaultPort})"; GroupDescription: "Network:"
+
+[Files]
+; Embedded Python (entire tree)
+Source: "build\staging\python\*"; DestDir: "{app}\python"; Flags: recursesubdirs ignoreversion
+; Backend + frontend
+Source: "build\staging\app\*"; DestDir: "{app}\app"; Flags: recursesubdirs ignoreversion
+; NSSM, ffmpeg, ffprobe
+Source: "build\staging\bin\*"; DestDir: "{app}\bin"; Flags: recursesubdirs ignoreversion
+; Service install/uninstall scripts
+Source: "build\staging\service\*"; DestDir: "{app}\service"; Flags: recursesubdirs ignoreversion
+; Version stamp
+Source: "build\staging\VERSION"; DestDir: "{app}"; Flags: ignoreversion
+
+[Dirs]
+; ProgramData layout — created with permissions LocalSystem can write to
+Name: "{commonappdata}\Bambuddy"; Permissions: users-modify
+Name: "{commonappdata}\Bambuddy\data"; Permissions: users-modify
+Name: "{commonappdata}\Bambuddy\logs"; Permissions: users-modify
+
+[Icons]
+Name: "{group}\Open Bambuddy Dashboard"; Filename: "http://localhost:{#DefaultPort}"; IconFilename: "{app}\bin\nssm.exe"
+Name: "{group}\Bambuddy Logs"; Filename: "{commonappdata}\Bambuddy\logs"
+Name: "{group}\Uninstall Bambuddy"; Filename: "{uninstallexe}"
+Name: "{commondesktop}\Bambuddy"; Filename: "http://localhost:{#DefaultPort}"; IconFilename: "{app}\bin\nssm.exe"; Tasks: desktopicon
+
+[Run]
+; Register and start the Windows service
+Filename: "{app}\service\install-service.bat"; Parameters: """{app}"" ""{commonappdata}\Bambuddy"" {#DefaultPort}"; Flags: runhidden waituntilterminated; StatusMsg: "Registering Bambuddy service..."
+
+; Open Windows Firewall on the dashboard port. We do this only if the
+; user opted in via the firewallrule task — some environments manage
+; firewall centrally and prefer to handle this themselves.
+Filename: "netsh.exe"; Parameters: "advfirewall firewall add rule name=""Bambuddy Dashboard"" dir=in action=allow protocol=TCP localport={#DefaultPort}"; Flags: runhidden waituntilterminated; Tasks: firewallrule; StatusMsg: "Adding firewall rule..."
+
+; Open the dashboard in the user's default browser at the end of install
+Filename: "http://localhost:{#DefaultPort}"; Flags: shellexec postinstall nowait skipifsilent; Description: "Open Bambuddy Dashboard"
+
+[UninstallRun]
+; Stop + deregister the service before file removal
+Filename: "{app}\service\uninstall-service.bat"; Parameters: """{app}"""; Flags: runhidden waituntilterminated
+
+; Remove the firewall rule (silently — if it doesn't exist, netsh just complains)
+Filename: "netsh.exe"; Parameters: "advfirewall firewall delete rule name=""Bambuddy Dashboard"""; Flags: runhidden waituntilterminated
+
+[UninstallDelete]
+; Remove install dir contents; leave ProgramData\Bambuddy alone so the
+; user keeps their database + archives. Re-installing on top picks them
+; back up automatically.
+Type: filesandordirs; Name: "{app}"
+
+[Code]
+// Pre-install check: refuse to install if port 8000 is already in use by
+// something other than a previous Bambuddy install. This catches the
+// "I have something else on 8000" case early instead of after install.
+function InitializeSetup(): Boolean;
+begin
+  Result := True;
+  // TODO: optional port-conflict check. Inno Setup doesn't have a
+  // native socket API; would need a tiny helper exe or a netstat parse.
+  // Defer to v1.1 — for v1, accept that conflicts surface at first
+  // service start and the user reads the log.
+end;

+ 325 - 0
installers/windows/build.py

@@ -0,0 +1,325 @@
+"""Build script for the Bambuddy Windows installer.
+
+Stages all artifacts under ``installers/windows/build/staging/`` for the
+Inno Setup compiler to package. Run this on Windows (or in a Windows CI
+runner) — it pip-installs Bambuddy's deps against the embedded Python it
+downloads, which requires the matching platform.
+
+Steps:
+    1. Download python.org embeddable distribution for Windows x64
+    2. Configure embedded Python (allow site-packages)
+    3. Bootstrap pip into the embedded distribution
+    4. Install ``requirements.txt`` into the embedded Python
+    5. Build the React frontend (``frontend/npm run build``)
+    6. Stage backend source + frontend bundle
+    7. Download NSSM
+    8. Download ffmpeg static build for Windows
+    9. Print "ready for ISCC" message
+
+After this script succeeds, run::
+
+    "C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe" bambuddy.iss
+
+to produce the final installer .exe under ``build/output/``.
+"""
+
+from __future__ import annotations
+
+import argparse
+import shutil
+import subprocess
+import sys
+import urllib.request
+import zipfile
+from pathlib import Path
+
+# Repo root: installers/windows/build.py -> ../../
+REPO_ROOT = Path(__file__).resolve().parents[2]
+INSTALLER_DIR = Path(__file__).resolve().parent
+BUILD_DIR = INSTALLER_DIR / "build"
+STAGING = BUILD_DIR / "staging"
+DOWNLOADS = BUILD_DIR / "downloads"
+
+# Python 3.13 — matches Dockerfile (python:3.13-slim-trixie). Bump when
+# the Dockerfile bumps; the Windows installer should track production.
+PYTHON_VERSION = "3.13.1"
+PYTHON_EMBED_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip"
+
+# NSSM 2.24 is the long-time stable build. The official site has been
+# unreliable; use the GitHub mirror that nssm.cc itself links to.
+NSSM_VERSION = "2.24"
+NSSM_URL = f"https://nssm.cc/release/nssm-{NSSM_VERSION}.zip"
+
+# ffmpeg static build. BtbN's gyan-equivalent build is the most reliable
+# automated source. Pin to a release tag so builds are reproducible.
+FFMPEG_URL = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
+
+# get-pip.py for bootstrapping pip into the embedded distribution
+GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py"
+
+
+def log(msg: str) -> None:
+    print(f"[build] {msg}", flush=True)
+
+
+def download(url: str, dest: Path) -> Path:
+    """Download ``url`` to ``dest`` if not already present."""
+    if dest.exists():
+        log(f"already downloaded: {dest.name}")
+        return dest
+    dest.parent.mkdir(parents=True, exist_ok=True)
+    log(f"downloading {url}")
+    with urllib.request.urlopen(url) as resp, open(dest, "wb") as f:  # noqa: S310 — pinned URLs
+        shutil.copyfileobj(resp, f)
+    return dest
+
+
+def unzip(zip_path: Path, dest: Path) -> None:
+    log(f"unzipping {zip_path.name} -> {dest}")
+    dest.mkdir(parents=True, exist_ok=True)
+    with zipfile.ZipFile(zip_path) as zf:
+        zf.extractall(dest)
+
+
+def stage_embedded_python() -> Path:
+    """Download and configure the embedded Python distribution."""
+    target = STAGING / "python"
+    if target.exists():
+        shutil.rmtree(target)
+
+    zip_path = download(
+        PYTHON_EMBED_URL,
+        DOWNLOADS / f"python-{PYTHON_VERSION}-embed-amd64.zip",
+    )
+    unzip(zip_path, target)
+
+    # Edit pythonXY._pth to allow site-packages. The embedded distribution
+    # ships with `import site` commented out — uncomment it so pip-installed
+    # packages in Lib\site-packages are importable.
+    pth_files = list(target.glob("python3*._pth"))
+    if not pth_files:
+        raise RuntimeError(f"no python3*._pth file found in {target}")
+    pth = pth_files[0]
+    content = pth.read_text()
+    content = content.replace("#import site", "import site")
+    # Also add Lib\site-packages explicitly. The embedded distribution
+    # doesn't include this path by default even with `import site` enabled.
+    if "Lib\\site-packages" not in content and "Lib/site-packages" not in content:
+        content = content.rstrip() + "\nLib\\site-packages\n"
+    pth.write_text(content)
+
+    # Bootstrap pip
+    get_pip = download(GET_PIP_URL, DOWNLOADS / "get-pip.py")
+    log("bootstrapping pip into embedded Python")
+    subprocess.run(
+        [str(target / "python.exe"), str(get_pip), "--no-warn-script-location"],
+        check=True,
+    )
+
+    return target
+
+
+def install_requirements(python_dir: Path) -> None:
+    """Install Bambuddy's requirements.txt into the embedded Python."""
+    py = python_dir / "python.exe"
+    requirements = REPO_ROOT / "requirements.txt"
+    log(f"installing requirements.txt into {python_dir}")
+    subprocess.run(
+        [
+            str(py),
+            "-m",
+            "pip",
+            "install",
+            "--no-warn-script-location",
+            "-r",
+            str(requirements),
+        ],
+        check=True,
+    )
+
+
+def build_frontend() -> Path:
+    """Run ``npm ci && npm run build`` and return the dist path."""
+    frontend = REPO_ROOT / "frontend"
+    dist = frontend / "dist"
+    log("running npm ci in frontend/")
+    npm = shutil.which("npm")
+    if not npm:
+        raise RuntimeError("npm not found on PATH — install Node.js 22 LTS")
+    subprocess.run([npm, "ci"], cwd=frontend, check=True, shell=False)
+    log("running npm run build in frontend/")
+    subprocess.run([npm, "run", "build"], cwd=frontend, check=True, shell=False)
+    if not dist.exists():
+        raise RuntimeError(f"expected frontend build output at {dist}")
+    return dist
+
+
+def stage_backend(frontend_dist: Path) -> None:
+    """Copy backend source + frontend bundle into the staging tree.
+
+    The runtime layout under STAGING/app/ mirrors a Bambuddy checkout:
+    ``backend/`` (source), ``static/`` (frontend bundle served by FastAPI).
+    """
+    app = STAGING / "app"
+    if app.exists():
+        shutil.rmtree(app)
+    app.mkdir(parents=True)
+
+    # Backend source — copy the package tree, skip caches/tests/migrations
+    log("staging backend source")
+    shutil.copytree(
+        REPO_ROOT / "backend",
+        app / "backend",
+        ignore=shutil.ignore_patterns(
+            "__pycache__",
+            "*.pyc",
+            "tests",
+            ".pytest_cache",
+        ),
+    )
+
+    # Frontend bundle — FastAPI's StaticFiles mounts from app/static
+    log("staging frontend bundle")
+    shutil.copytree(frontend_dist, app / "static")
+
+
+def stage_nssm() -> None:
+    target = STAGING / "bin"
+    target.mkdir(parents=True, exist_ok=True)
+    zip_path = download(NSSM_URL, DOWNLOADS / f"nssm-{NSSM_VERSION}.zip")
+    extract = DOWNLOADS / f"nssm-{NSSM_VERSION}-extracted"
+    if not extract.exists():
+        unzip(zip_path, extract)
+    # The zip nests as nssm-2.24/win64/nssm.exe
+    src = next(extract.rglob("win64/nssm.exe"))
+    log(f"staging nssm.exe from {src}")
+    shutil.copy(src, target / "nssm.exe")
+
+
+def stage_ffmpeg() -> None:
+    target = STAGING / "bin"
+    target.mkdir(parents=True, exist_ok=True)
+    zip_path = download(FFMPEG_URL, DOWNLOADS / "ffmpeg-win64-gpl.zip")
+    extract = DOWNLOADS / "ffmpeg-extracted"
+    if not extract.exists():
+        unzip(zip_path, extract)
+    src = next(extract.rglob("bin/ffmpeg.exe"))
+    log(f"staging ffmpeg.exe from {src}")
+    shutil.copy(src, target / "ffmpeg.exe")
+    # ffprobe is used by some camera/timelapse paths
+    ffprobe = next(extract.rglob("bin/ffprobe.exe"), None)
+    if ffprobe is not None:
+        shutil.copy(ffprobe, target / "ffprobe.exe")
+
+
+def stage_service_scripts() -> None:
+    """Copy the service install/uninstall .bat files into staging."""
+    service_src = INSTALLER_DIR / "service"
+    service_dst = STAGING / "service"
+    if service_dst.exists():
+        shutil.rmtree(service_dst)
+    shutil.copytree(service_src, service_dst)
+
+
+def write_version_file() -> None:
+    """Write the installer version as both a plain VERSION file and an
+    Inno Setup include file so the .iss script can pick it up at compile
+    time without a fragile file-read hack.
+
+    Reads from pyproject.toml's [project] version line for the source of
+    truth. Falls back to ``0.0.0+dev`` if not parseable.
+    """
+    version = "0.0.0+dev"
+    pyproject = REPO_ROOT / "pyproject.toml"
+    if pyproject.exists():
+        for line in pyproject.read_text().splitlines():
+            line = line.strip()
+            if line.startswith("version =") or line.startswith('version="'):
+                # version = "0.1.5"  ->  0.1.5
+                version = line.split("=", 1)[1].strip().strip('"').strip("'")
+                break
+    (STAGING / "VERSION").write_text(version)
+
+    # Inno Setup include — bambuddy.iss does `#include "build\staging\version.iss"`
+    iss_version = STAGING / "version.iss"
+    iss_version.write_text(f'#define MyAppVersion "{version}"\n')
+    log(f"staged VERSION = {version}")
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "--skip-frontend",
+        action="store_true",
+        help="Skip frontend build (use existing frontend/dist/)",
+    )
+    parser.add_argument(
+        "--skip-pip",
+        action="store_true",
+        help="Skip pip install (use existing staged Python)",
+    )
+    parser.add_argument(
+        "--allow-non-windows",
+        action="store_true",
+        help=(
+            "Override the Windows-only guard. Only useful if you have a "
+            "working wine + windows-python toolchain. Not exercised by CI."
+        ),
+    )
+    args = parser.parse_args()
+
+    if sys.platform != "win32" and not args.allow_non_windows:
+        log("ERROR: this build script must run on Windows.")
+        log("")
+        log("It downloads a Windows embeddable Python distribution and")
+        log("pip-installs Bambuddy's requirements.txt against it — both")
+        log("require executing python.exe, which only runs on Windows.")
+        log("")
+        log("Supported build paths:")
+        log("  1. GitHub Actions: trigger '.github/workflows/windows-")
+        log("     installer.yml' (Actions tab -> Windows Installer ->")
+        log("     Run workflow). Downloads the .exe as a workflow artifact.")
+        log("  2. Windows VM / box: clone, install Python 3.13 + Node 22 +")
+        log("     Inno Setup 6, run this script.")
+        log("")
+        log("Unsupported escape hatch (cross-build under Wine): rerun with")
+        log("--allow-non-windows. Requires wine + a Windows Python in $PATH")
+        log("via wine python.exe — fragile and not exercised by CI.")
+        return 1
+
+    BUILD_DIR.mkdir(parents=True, exist_ok=True)
+    DOWNLOADS.mkdir(parents=True, exist_ok=True)
+    STAGING.mkdir(parents=True, exist_ok=True)
+
+    python_dir = stage_embedded_python()
+    if not args.skip_pip:
+        install_requirements(python_dir)
+
+    if args.skip_frontend:
+        frontend_dist = REPO_ROOT / "frontend" / "dist"
+        if not frontend_dist.exists():
+            raise RuntimeError("--skip-frontend given but frontend/dist/ doesn't exist")
+    else:
+        frontend_dist = build_frontend()
+
+    stage_backend(frontend_dist)
+    stage_nssm()
+    stage_ffmpeg()
+    stage_service_scripts()
+    write_version_file()
+
+    log("")
+    log("=" * 60)
+    log("Staging complete.")
+    log(f"Staged tree: {STAGING}")
+    log("")
+    log("Next: compile the Inno Setup script:")
+    log('  "C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe" bambuddy.iss')
+    log("")
+    log(f"Installer will be written to: {BUILD_DIR / 'output'}")
+    log("=" * 60)
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 75 - 0
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

+ 22 - 0
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

Некоторые файлы не были показаны из-за большого количества измененных файлов