diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 04fb836..7db77e0 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -40,25 +40,35 @@ jobs: echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT" echo "Created release ID: $RELEASE_ID" - # ── Windows portable ZIP ─────────────────────────────────── + # ── Windows portable ZIP (cross-built from Linux) ───────── build-windows: needs: create-release - runs-on: windows-latest + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' - - name: Build portable distribution - shell: pwsh + - name: Install system dependencies run: | - .\build-dist.ps1 -Version "${{ gitea.ref_name }}" + sudo apt-get update + sudo apt-get install -y --no-install-recommends zip libportaudio2 + + - name: Cross-build Windows distribution + run: | + chmod +x build-dist-windows.sh + ./build-dist-windows.sh "${{ gitea.ref_name }}" - name: Upload build artifact uses: actions/upload-artifact@v3 @@ -68,7 +78,6 @@ jobs: retention-days: 90 - name: Attach ZIP to release - shell: bash env: GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} run: | diff --git a/build-dist-windows.sh b/build-dist-windows.sh new file mode 100644 index 0000000..29af016 --- /dev/null +++ b/build-dist-windows.sh @@ -0,0 +1,274 @@ +#!/usr/bin/env bash +# +# Cross-build a portable Windows distribution of LedGrab from Linux. +# +# Downloads Windows embedded Python and win_amd64 wheels — no Wine or +# Windows runner needed. Produces the same ZIP as build-dist.ps1. +# +# Usage: +# ./build-dist-windows.sh [VERSION] +# ./build-dist-windows.sh v0.1.0-alpha.1 +# +# Requirements: python3, pip, curl, unzip, zip, node/npm + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BUILD_DIR="$SCRIPT_DIR/build" +DIST_NAME="LedGrab" +DIST_DIR="$BUILD_DIR/$DIST_NAME" +SERVER_DIR="$SCRIPT_DIR/server" +PYTHON_DIR="$DIST_DIR/python" +APP_DIR="$DIST_DIR/app" +PYTHON_VERSION="${PYTHON_VERSION:-3.11.9}" + +# ── Version detection ──────────────────────────────────────── + +VERSION="${1:-}" + +if [ -z "$VERSION" ]; then + VERSION=$(git describe --tags --exact-match 2>/dev/null || true) +fi +if [ -z "$VERSION" ]; then + VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}" +fi +if [ -z "$VERSION" ]; then + VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' "$SERVER_DIR/src/wled_controller/__init__.py" 2>/dev/null || echo "0.0.0") +fi + +VERSION_CLEAN="${VERSION#v}" +ZIP_NAME="LedGrab-v${VERSION_CLEAN}-win-x64.zip" + +echo "=== Cross-building LedGrab v${VERSION_CLEAN} (Windows from Linux) ===" +echo " Embedded Python: $PYTHON_VERSION" +echo " Output: build/$ZIP_NAME" +echo "" + +# ── Clean ──────────────────────────────────────────────────── + +if [ -d "$DIST_DIR" ]; then + echo "[1/8] Cleaning previous build..." + rm -rf "$DIST_DIR" +fi +mkdir -p "$DIST_DIR" + +# ── Download Windows embedded Python ───────────────────────── + +PYTHON_ZIP_URL="https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-embed-amd64.zip" +PYTHON_ZIP_PATH="$BUILD_DIR/python-embed-win.zip" + +echo "[2/8] Downloading Windows embedded Python ${PYTHON_VERSION}..." +if [ ! -f "$PYTHON_ZIP_PATH" ]; then + curl -sL "$PYTHON_ZIP_URL" -o "$PYTHON_ZIP_PATH" +fi +mkdir -p "$PYTHON_DIR" +unzip -qo "$PYTHON_ZIP_PATH" -d "$PYTHON_DIR" + +# ── Patch ._pth to enable site-packages ────────────────────── + +echo "[3/8] Patching Python path configuration..." +PTH_FILE=$(ls "$PYTHON_DIR"/python*._pth 2>/dev/null | head -1) +if [ -z "$PTH_FILE" ]; then + echo "ERROR: Could not find python*._pth in $PYTHON_DIR" >&2 + exit 1 +fi + +# Uncomment 'import site' and add Lib\site-packages +sed -i 's/^#\s*import site/import site/' "$PTH_FILE" +if ! grep -q 'Lib\\site-packages' "$PTH_FILE"; then + echo 'Lib\site-packages' >> "$PTH_FILE" +fi +echo " Patched $(basename "$PTH_FILE")" + +# ── Download pip and install into embedded Python ──────────── + +echo "[4/8] Installing pip into embedded Python..." +SITE_PACKAGES="$PYTHON_DIR/Lib/site-packages" +mkdir -p "$SITE_PACKAGES" + +# Download pip + setuptools wheels for Windows +pip download --quiet --dest "$BUILD_DIR/pip-wheels" \ + --platform win_amd64 --python-version "3.11" \ + --implementation cp --only-binary :all: \ + pip setuptools 2>/dev/null || \ +pip download --quiet --dest "$BUILD_DIR/pip-wheels" \ + pip setuptools + +# Unzip pip into site-packages (we just need it to exist, not to run) +for whl in "$BUILD_DIR/pip-wheels"/pip-*.whl; do + unzip -qo "$whl" -d "$SITE_PACKAGES" +done +for whl in "$BUILD_DIR/pip-wheels"/setuptools-*.whl; do + unzip -qo "$whl" -d "$SITE_PACKAGES" +done + +# ── Download Windows wheels for all dependencies ───────────── + +echo "[5/8] Downloading Windows dependencies..." +WHEEL_DIR="$BUILD_DIR/win-wheels" +mkdir -p "$WHEEL_DIR" + +# Core dependencies (cross-platform, should have win_amd64 wheels) +# We parse pyproject.toml deps and download win_amd64 wheels. +# For packages that are pure Python, --only-binary will fail, +# so we fall back to allowing source for those. +DEPS=( + "fastapi>=0.115.0" + "uvicorn[standard]>=0.32.0" + "httpx>=0.27.2" + "mss>=9.0.2" + "Pillow>=10.4.0" + "numpy>=2.1.3" + "pydantic>=2.9.2" + "pydantic-settings>=2.6.0" + "PyYAML>=6.0.2" + "structlog>=24.4.0" + "python-json-logger>=3.1.0" + "python-dateutil>=2.9.0" + "python-multipart>=0.0.12" + "jinja2>=3.1.0" + "zeroconf>=0.131.0" + "pyserial>=3.5" + "psutil>=5.9.0" + "nvidia-ml-py>=12.0.0" + "sounddevice>=0.5" + "aiomqtt>=2.0.0" + "openrgb-python>=0.2.15" + # camera extra + "opencv-python-headless>=4.8.0" +) + +# Windows-only deps +WIN_DEPS=( + "wmi>=1.5.1" + "PyAudioWPatch>=0.2.12" + "winsdk>=1.0.0b10" +) + +# Download cross-platform deps (prefer binary, allow source for pure Python) +for dep in "${DEPS[@]}"; do + pip download --quiet --dest "$WHEEL_DIR" \ + --platform win_amd64 --python-version "3.11" \ + --implementation cp --only-binary :all: \ + "$dep" 2>/dev/null || \ + pip download --quiet --dest "$WHEEL_DIR" \ + --platform win_amd64 --python-version "3.11" \ + --implementation cp \ + "$dep" 2>/dev/null || \ + pip download --quiet --dest "$WHEEL_DIR" "$dep" 2>/dev/null || \ + echo " WARNING: Could not download $dep (skipping)" +done + +# Download Windows-only deps (best effort) +for dep in "${WIN_DEPS[@]}"; do + pip download --quiet --dest "$WHEEL_DIR" \ + --platform win_amd64 --python-version "3.11" \ + --implementation cp --only-binary :all: \ + "$dep" 2>/dev/null || \ + pip download --quiet --dest "$WHEEL_DIR" \ + --platform win_amd64 --python-version "3.11" \ + --implementation cp \ + "$dep" 2>/dev/null || \ + echo " WARNING: Could not download $dep (skipping, Windows-only)" +done + +# Install all downloaded wheels into site-packages +echo " Installing $(ls "$WHEEL_DIR"/*.whl 2>/dev/null | wc -l) wheels into site-packages..." +for whl in "$WHEEL_DIR"/*.whl; do + [ -f "$whl" ] && unzip -qo "$whl" -d "$SITE_PACKAGES" 2>/dev/null || true +done + +# Also extract any .tar.gz source packages (pure Python only) +for sdist in "$WHEEL_DIR"/*.tar.gz; do + [ -f "$sdist" ] || continue + TMPDIR=$(mktemp -d) + tar -xzf "$sdist" -C "$TMPDIR" 2>/dev/null || continue + # Find the package directory inside and copy it + PKG_DIR=$(find "$TMPDIR" -maxdepth 2 -name "*.py" -path "*/setup.py" -exec dirname {} \; | head -1) + if [ -n "$PKG_DIR" ] && [ -d "$PKG_DIR/src" ]; then + cp -r "$PKG_DIR/src/"* "$SITE_PACKAGES/" 2>/dev/null || true + elif [ -n "$PKG_DIR" ]; then + # Copy any Python package directories + find "$PKG_DIR" -maxdepth 1 -type d -name "[a-z]*" -exec cp -r {} "$SITE_PACKAGES/" \; 2>/dev/null || true + fi + rm -rf "$TMPDIR" +done + +# Remove dist-info, caches, tests to reduce size +find "$SITE_PACKAGES" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true +find "$SITE_PACKAGES" -type d -name tests -exec rm -rf {} + 2>/dev/null || true +find "$SITE_PACKAGES" -type d -name test -exec rm -rf {} + 2>/dev/null || true + +# Remove wled_controller if it got installed +rm -rf "$SITE_PACKAGES"/wled_controller* "$SITE_PACKAGES"/wled*.dist-info 2>/dev/null || true + +WHEEL_COUNT=$(ls "$WHEEL_DIR"/*.whl 2>/dev/null | wc -l) +echo " Installed $WHEEL_COUNT packages" + +# ── Build frontend ─────────────────────────────────────────── + +echo "[6/8] Building frontend bundle..." +(cd "$SERVER_DIR" && npm ci --loglevel error && npm run build) 2>&1 | { + grep -v 'RemoteException' || true +} + +# ── Copy application files ─────────────────────────────────── + +echo "[7/8] Copying application files..." +mkdir -p "$APP_DIR" + +cp -r "$SERVER_DIR/src" "$APP_DIR/src" +cp -r "$SERVER_DIR/config" "$APP_DIR/config" +mkdir -p "$DIST_DIR/data" "$DIST_DIR/logs" + +# Clean up source maps and __pycache__ +find "$APP_DIR" -name "*.map" -delete 2>/dev/null || true +find "$APP_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + +# ── Create launcher ────────────────────────────────────────── + +echo "[8/8] Creating launcher and packaging..." + +cat > "$DIST_DIR/LedGrab.bat" << LAUNCHER +@echo off +title LedGrab v${VERSION_CLEAN} +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_CLEAN} +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 +LAUNCHER + +# Convert launcher to Windows line endings +sed -i 's/$/\r/' "$DIST_DIR/LedGrab.bat" + +# ── Create ZIP ─────────────────────────────────────────────── + +ZIP_PATH="$BUILD_DIR/$ZIP_NAME" +rm -f "$ZIP_PATH" + +(cd "$BUILD_DIR" && zip -rq "$ZIP_NAME" "$DIST_NAME") + +ZIP_SIZE=$(du -h "$ZIP_PATH" | cut -f1) +echo "" +echo "=== Build complete ===" +echo " Archive: $ZIP_PATH" +echo " Size: $ZIP_SIZE" +echo ""