<# .SYNOPSIS Build a portable Windows distribution of LedGrab. .DESCRIPTION Downloads embedded Python, installs all dependencies, copies app code, builds the frontend bundle, and produces a self-contained ZIP. .PARAMETER Version Version string (e.g. "0.1.0" or "v0.1.0"). Auto-detected from git tag or __init__.py if omitted. .PARAMETER PythonVersion Embedded Python version to download. Default: 3.11.9 .PARAMETER SkipFrontend Skip npm ci + npm run build (use if frontend is already built). .PARAMETER SkipPerf Skip installing optional [perf] extras (dxcam, bettercam, windows-capture). .EXAMPLE .\build-dist.ps1 .\build-dist.ps1 -Version "0.2.0" .\build-dist.ps1 -SkipFrontend -SkipPerf #> param( [string]$Version = "", [string]$PythonVersion = "3.11.9", [switch]$SkipFrontend, [switch]$SkipPerf ) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' # faster downloads $ScriptRoot = $PSScriptRoot $BuildDir = Join-Path $ScriptRoot "build" $DistName = "LedGrab" $DistDir = Join-Path $BuildDir $DistName $ServerDir = Join-Path $ScriptRoot "server" $PythonDir = Join-Path $DistDir "python" $AppDir = Join-Path $DistDir "app" # ── Version detection ────────────────────────────────────────── if (-not $Version) { # Try git tag try { $gitTag = git describe --tags --exact-match 2>$null if ($gitTag) { $Version = $gitTag } } catch {} } if (-not $Version) { # Try env var (CI) if ($env:GITEA_REF_NAME) { $Version = $env:GITEA_REF_NAME } elseif ($env:GITHUB_REF_NAME) { $Version = $env:GITHUB_REF_NAME } } if (-not $Version) { # Parse from __init__.py $initFile = Join-Path $ServerDir "src\wled_controller\__init__.py" $match = Select-String -Path $initFile -Pattern '__version__\s*=\s*"([^"]+)"' if ($match) { $Version = $match.Matches[0].Groups[1].Value } } if (-not $Version) { $Version = "0.0.0" } # Strip leading 'v' for filenames $VersionClean = $Version -replace '^v', '' $ZipName = "LedGrab-v${VersionClean}-win-x64.zip" Write-Host "=== Building LedGrab v${VersionClean} ===" -ForegroundColor Cyan Write-Host " Python: $PythonVersion" Write-Host " Output: build\$ZipName" Write-Host "" # ── Clean ────────────────────────────────────────────────────── if (Test-Path $DistDir) { Write-Host "[1/8] Cleaning previous build..." Remove-Item -Recurse -Force $DistDir } New-Item -ItemType Directory -Path $DistDir -Force | Out-Null # ── Download embedded Python ─────────────────────────────────── $PythonZipUrl = "https://www.python.org/ftp/python/${PythonVersion}/python-${PythonVersion}-embed-amd64.zip" $PythonZipPath = Join-Path $BuildDir "python-embed.zip" Write-Host "[2/8] Downloading embedded Python ${PythonVersion}..." if (-not (Test-Path $PythonZipPath)) { Invoke-WebRequest -Uri $PythonZipUrl -OutFile $PythonZipPath } Write-Host " Extracting to python/..." Expand-Archive -Path $PythonZipPath -DestinationPath $PythonDir -Force # ── Patch ._pth to enable site-packages ──────────────────────── Write-Host "[3/8] Patching Python path configuration..." $pthFile = Get-ChildItem -Path $PythonDir -Filter "python*._pth" | Select-Object -First 1 if (-not $pthFile) { throw "Could not find python*._pth in $PythonDir" } $pthContent = Get-Content $pthFile.FullName -Raw # Uncomment 'import site' $pthContent = $pthContent -replace '#\s*import site', 'import site' # Add Lib\site-packages if not present if ($pthContent -notmatch 'Lib\\site-packages') { $pthContent = $pthContent.TrimEnd() + "`nLib\site-packages`n" } Set-Content -Path $pthFile.FullName -Value $pthContent -NoNewline Write-Host " Patched $($pthFile.Name)" # ── Install pip ──────────────────────────────────────────────── Write-Host "[4/8] Installing pip..." $GetPipPath = Join-Path $BuildDir "get-pip.py" if (-not (Test-Path $GetPipPath)) { Invoke-WebRequest -Uri "https://bootstrap.pypa.io/get-pip.py" -OutFile $GetPipPath } $python = Join-Path $PythonDir "python.exe" $ErrorActionPreference = 'Continue' & $python $GetPipPath --no-warn-script-location 2>&1 | Out-Null $ErrorActionPreference = 'Stop' if ($LASTEXITCODE -ne 0) { throw "Failed to install pip" } # ── Install dependencies ────────────────────────────────────── Write-Host "[5/8] Installing dependencies..." $extras = "camera,notifications" if (-not $SkipPerf) { $extras += ",perf" } # Install the project (pulls all deps via pyproject.toml), then remove # the installed package itself — PYTHONPATH handles app code loading. $ErrorActionPreference = 'Continue' & $python -m pip install --no-warn-script-location "${ServerDir}[${extras}]" 2>&1 | ForEach-Object { if ($_ -match 'ERROR|Failed') { Write-Host " $_" -ForegroundColor Red } } $ErrorActionPreference = 'Stop' if ($LASTEXITCODE -ne 0) { Write-Host " Some optional deps may have failed (continuing)..." -ForegroundColor Yellow } # Remove the installed wled_controller package to avoid duplication $sitePackages = Join-Path $PythonDir "Lib\site-packages" Get-ChildItem -Path $sitePackages -Filter "wled*" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue Get-ChildItem -Path $sitePackages -Filter "wled*.dist-info" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue # Clean up caches and test files to reduce size Write-Host " Cleaning up caches..." Get-ChildItem -Path $sitePackages -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue Get-ChildItem -Path $sitePackages -Recurse -Directory -Filter "tests" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue Get-ChildItem -Path $sitePackages -Recurse -Directory -Filter "test" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue # ── Build frontend ───────────────────────────────────────────── if (-not $SkipFrontend) { Write-Host "[6/8] Building frontend bundle..." Push-Location $ServerDir try { $ErrorActionPreference = 'Continue' & npm ci --loglevel error 2>&1 | Out-Null & npm run build 2>&1 | ForEach-Object { $line = "$_" if ($line -and $line -notmatch 'RemoteException') { Write-Host " $line" } } $ErrorActionPreference = 'Stop' } finally { Pop-Location } } else { Write-Host "[6/8] Skipping frontend build (--SkipFrontend)" } # ── Copy application files ───────────────────────────────────── Write-Host "[7/8] Copying application files..." New-Item -ItemType Directory -Path $AppDir -Force | Out-Null # Copy source code (includes static/dist bundle, templates, locales) $srcDest = Join-Path $AppDir "src" Copy-Item -Path (Join-Path $ServerDir "src") -Destination $srcDest -Recurse # Copy config $configDest = Join-Path $AppDir "config" Copy-Item -Path (Join-Path $ServerDir "config") -Destination $configDest -Recurse # Create empty data/ and logs/ directories New-Item -ItemType Directory -Path (Join-Path $DistDir "data") -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $DistDir "logs") -Force | Out-Null # Clean up source maps and __pycache__ from app code Get-ChildItem -Path $srcDest -Recurse -Filter "*.map" | Remove-Item -Force -ErrorAction SilentlyContinue Get-ChildItem -Path $srcDest -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue # ── Create launcher ──────────────────────────────────────────── Write-Host "[8/8] Creating launcher..." $launcherContent = @' @echo off title LedGrab v%VERSION% cd /d "%~dp0" :: Set paths set PYTHONPATH=%~dp0app\src set WLED_CONFIG_PATH=%~dp0app\config\default_config.yaml :: Create data directory if missing if not exist "%~dp0data" mkdir "%~dp0data" if not exist "%~dp0logs" mkdir "%~dp0logs" echo. echo ============================================= echo LedGrab v%VERSION% echo Open http://localhost:8080 in your browser echo ============================================= echo. :: Start the server (open browser after short delay) start "" /b cmd /c "timeout /t 2 /nobreak >nul && start http://localhost:8080" "%~dp0python\python.exe" -m uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080 pause '@ $launcherContent = $launcherContent -replace '%VERSION%', $VersionClean $launcherPath = Join-Path $DistDir "LedGrab.bat" Set-Content -Path $launcherPath -Value $launcherContent -Encoding ASCII # ── Create ZIP ───────────────────────────────────────────────── $ZipPath = Join-Path $BuildDir $ZipName if (Test-Path $ZipPath) { Remove-Item -Force $ZipPath } Write-Host "" Write-Host "Creating $ZipName..." -ForegroundColor Cyan # Use 7-Zip if available (faster, handles locked files), else fall back to Compress-Archive $7z = Get-Command 7z -ErrorAction SilentlyContinue if ($7z) { & 7z a -tzip -mx=7 $ZipPath "$DistDir\*" | Select-Object -Last 3 } else { Compress-Archive -Path "$DistDir\*" -DestinationPath $ZipPath -CompressionLevel Optimal } $zipSize = (Get-Item $ZipPath).Length / 1MB Write-Host "" Write-Host "=== Build complete ===" -ForegroundColor Green Write-Host " Archive: $ZipPath" Write-Host " Size: $([math]::Round($zipSize, 1)) MB" Write-Host ""