Some checks failed
Build Release / create-release (push) Successful in 5s
Lint & Test / test (push) Failing after 21s
Build Release / build-linux (push) Successful in 1m7s
Build Release / build-docker (push) Failing after 4s
Build Release / build-windows (push) Successful in 1m50s
Download _tkinter.pyd, tkinter package, and Tcl/Tk DLLs from the official Python nuget package and copy them into the embedded Python directory. This enables the screen overlay visualization during calibration in the portable build.
398 lines
13 KiB
Bash
398 lines
13 KiB
Bash
#!/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', add Lib\site-packages and app source path
|
|
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
|
|
# Embedded Python ._pth overrides PYTHONPATH, so we must add the app
|
|
# source directory here for wled_controller to be importable
|
|
if ! grep -q '\.\./app/src' "$PTH_FILE"; then
|
|
echo '../app/src' >> "$PTH_FILE"
|
|
fi
|
|
echo " Patched $(basename "$PTH_FILE")"
|
|
|
|
# ── Bundle tkinter into embedded Python ───────────────────────
|
|
# Embedded Python doesn't include tkinter. We download it from the
|
|
# official Windows Python nuget package (same version) which contains
|
|
# the _tkinter.pyd, tkinter/ package, and Tcl/Tk DLLs.
|
|
|
|
echo "[3b/8] Bundling tkinter for screen overlay support..."
|
|
|
|
# Python minor version for nuget package (e.g., 3.11.9 -> 3.11)
|
|
PYTHON_MINOR="${PYTHON_VERSION%.*}"
|
|
|
|
# Download the full Python nuget package (contains all stdlib + DLLs)
|
|
NUGET_URL="https://www.nuget.org/api/v2/package/python/${PYTHON_VERSION}"
|
|
NUGET_PKG="$BUILD_DIR/python-nuget.zip"
|
|
if [ ! -f "$NUGET_PKG" ]; then
|
|
curl -sL "$NUGET_URL" -o "$NUGET_PKG"
|
|
fi
|
|
|
|
NUGET_DIR="$BUILD_DIR/python-nuget"
|
|
rm -rf "$NUGET_DIR"
|
|
mkdir -p "$NUGET_DIR"
|
|
unzip -qo "$NUGET_PKG" -d "$NUGET_DIR"
|
|
|
|
# Copy _tkinter.pyd (the C extension)
|
|
TKINTER_PYD=$(find "$NUGET_DIR" -name "_tkinter.pyd" | head -1)
|
|
if [ -n "$TKINTER_PYD" ]; then
|
|
cp "$TKINTER_PYD" "$PYTHON_DIR/"
|
|
echo " Copied _tkinter.pyd"
|
|
else
|
|
echo " WARNING: _tkinter.pyd not found in nuget package"
|
|
fi
|
|
|
|
# Copy tkinter Python package from the stdlib zip or Lib/
|
|
# The nuget package has Lib/tkinter/
|
|
TKINTER_PKG=$(find "$NUGET_DIR" -type d -name "tkinter" | head -1)
|
|
if [ -n "$TKINTER_PKG" ]; then
|
|
mkdir -p "$PYTHON_DIR/Lib"
|
|
cp -r "$TKINTER_PKG" "$PYTHON_DIR/Lib/tkinter"
|
|
echo " Copied tkinter/ package"
|
|
else
|
|
echo " WARNING: tkinter package not found in nuget package"
|
|
fi
|
|
|
|
# Copy Tcl/Tk DLLs (tcl86t.dll, tk86t.dll, etc.)
|
|
for dll in tcl86t.dll tk86t.dll; do
|
|
DLL_PATH=$(find "$NUGET_DIR" -name "$dll" | head -1)
|
|
if [ -n "$DLL_PATH" ]; then
|
|
cp "$DLL_PATH" "$PYTHON_DIR/"
|
|
echo " Copied $dll"
|
|
fi
|
|
done
|
|
|
|
# Copy Tcl/Tk data directories (tcl8.6, tk8.6)
|
|
for tcldir in tcl8.6 tk8.6; do
|
|
TCL_PATH=$(find "$NUGET_DIR" -type d -name "$tcldir" | head -1)
|
|
if [ -n "$TCL_PATH" ]; then
|
|
cp -r "$TCL_PATH" "$PYTHON_DIR/$tcldir"
|
|
echo " Copied $tcldir/"
|
|
fi
|
|
done
|
|
|
|
# Add Lib to ._pth so tkinter package is importable
|
|
if ! grep -q '^Lib$' "$PTH_FILE"; then
|
|
echo 'Lib' >> "$PTH_FILE"
|
|
fi
|
|
|
|
rm -rf "$NUGET_DIR"
|
|
echo " tkinter bundled successfully"
|
|
|
|
# ── 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"
|
|
|
|
:: Start the server — reads port from config, prints its own banner
|
|
"%~dp0python\python.exe" -m wled_controller.main
|
|
|
|
pause
|
|
LAUNCHER
|
|
|
|
# Convert launcher to Windows line endings
|
|
sed -i 's/$/\r/' "$DIST_DIR/LedGrab.bat"
|
|
|
|
# ── Create autostart scripts ─────────────────────────────────
|
|
|
|
cat > "$DIST_DIR/install-autostart.bat" << 'AUTOSTART'
|
|
@echo off
|
|
:: Install LedGrab to start automatically on Windows login
|
|
:: Creates a shortcut in the Startup folder
|
|
|
|
set SHORTCUT_NAME=LedGrab
|
|
set STARTUP_DIR=%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup
|
|
set TARGET=%~dp0LedGrab.bat
|
|
set SHORTCUT=%STARTUP_DIR%\%SHORTCUT_NAME%.lnk
|
|
|
|
echo Installing LedGrab autostart...
|
|
|
|
:: Use PowerShell to create a proper shortcut
|
|
powershell -NoProfile -Command ^
|
|
"$ws = New-Object -ComObject WScript.Shell; ^
|
|
$sc = $ws.CreateShortcut('%SHORTCUT%'); ^
|
|
$sc.TargetPath = '%TARGET%'; ^
|
|
$sc.WorkingDirectory = '%~dp0'; ^
|
|
$sc.WindowStyle = 7; ^
|
|
$sc.Description = 'LedGrab ambient lighting server'; ^
|
|
$sc.Save()"
|
|
|
|
if exist "%SHORTCUT%" (
|
|
echo.
|
|
echo [OK] LedGrab will start automatically on login.
|
|
echo Shortcut: %SHORTCUT%
|
|
echo.
|
|
echo To remove: run uninstall-autostart.bat
|
|
) else (
|
|
echo.
|
|
echo [ERROR] Failed to create shortcut.
|
|
)
|
|
|
|
pause
|
|
AUTOSTART
|
|
sed -i 's/$/\r/' "$DIST_DIR/install-autostart.bat"
|
|
|
|
cat > "$DIST_DIR/uninstall-autostart.bat" << 'UNAUTOSTART'
|
|
@echo off
|
|
:: Remove LedGrab from Windows startup
|
|
|
|
set SHORTCUT=%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\LedGrab.lnk
|
|
|
|
if exist "%SHORTCUT%" (
|
|
del "%SHORTCUT%"
|
|
echo.
|
|
echo [OK] LedGrab autostart removed.
|
|
) else (
|
|
echo.
|
|
echo LedGrab autostart was not installed.
|
|
)
|
|
|
|
pause
|
|
UNAUTOSTART
|
|
sed -i 's/$/\r/' "$DIST_DIR/uninstall-autostart.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 ""
|