refactor: move build scripts to build/ directory
Lint & Test / test (push) Successful in 2m21s

Declutters the repo root by consolidating build-common.sh,
build-dist.sh, build-dist-windows.sh, build-dist.ps1, and installer.nsi
into build/. Updates all path references in CI workflows, NSIS installer,
and documentation.
This commit is contained in:
2026-04-12 23:16:40 +03:00
parent 02cd9d519c
commit a0b65e3fcb
9 changed files with 43 additions and 32 deletions
+273
View File
@@ -0,0 +1,273 @@
#!/usr/bin/env bash
#
# Shared build functions for LedGrab distribution packaging.
# Sourced by build-dist.sh (Linux) and build-dist-windows.sh (Windows).
#
# Expected variables set by the caller before sourcing:
# SCRIPT_DIR, BUILD_DIR, DIST_DIR, SERVER_DIR, APP_DIR
# ── Version detection ────────────────────────────────────────
detect_version() {
# Usage: detect_version [explicit_version]
local 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/pyproject.toml" 2>/dev/null || echo "0.0.0")
fi
VERSION_CLEAN="${version#v}"
# Normalize non-PEP440 version labels (e.g. "dev", "nightly", "snapshot")
# to a valid PEP440 dev release. Without this, pip/setuptools rejects the
# pyproject.toml with: `project.version` must be pep440.
if ! [[ "$VERSION_CLEAN" =~ ^[0-9]+(\.[0-9]+)*((a|b|rc|\.dev|\.post)[0-9]+)*(\+[a-zA-Z0-9.]+)?$ ]]; then
echo " Warning: '$VERSION_CLEAN' is not PEP440-compliant, using 0.0.0.dev0"
VERSION_CLEAN="0.0.0.dev0"
fi
# Stamp the resolved version into pyproject.toml so that
# importlib.metadata reads the correct value at runtime.
sed -i "s/^version = .*/version = \"${VERSION_CLEAN}\"/" "$SERVER_DIR/pyproject.toml"
}
# ── Clean previous build ─────────────────────────────────────
clean_dist() {
if [ -d "$DIST_DIR" ]; then
echo " Cleaning previous build..."
rm -rf "$DIST_DIR"
fi
mkdir -p "$DIST_DIR"
}
# ── Build frontend ───────────────────────────────────────────
build_frontend() {
echo " Building frontend bundle..."
(cd "$SERVER_DIR" && npm ci --loglevel error && npm run build) 2>&1 | {
grep -v 'RemoteException' || true
}
}
# ── Copy application files ───────────────────────────────────
copy_app_files() {
echo " 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
}
# ── Site-packages cleanup ────────────────────────────────────
#
# Strips tests, type stubs, unused submodules, and debug symbols
# from the installed site-packages directory.
#
# Args:
# $1 — path to site-packages directory
# $2 — native extension suffix: "pyd" (Windows) or "so" (Linux)
# $3 — native lib suffix for OpenCV ffmpeg: "dll" or "so"
cleanup_site_packages() {
local sp_dir="$1"
local ext_suffix="${2:-so}"
local lib_suffix="${3:-so}"
echo " Cleaning up site-packages to reduce size..."
# ── Generic cleanup ──────────────────────────────────────
find "$sp_dir" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find "$sp_dir" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
find "$sp_dir" -type d -name test -exec rm -rf {} + 2>/dev/null || true
find "$sp_dir" -type d -name "*.dist-info" -exec rm -rf {} + 2>/dev/null || true
find "$sp_dir" -name "*.pyi" -delete 2>/dev/null || true
# ── pip / setuptools (not needed at runtime) ─────────────
rm -rf "$sp_dir"/pip "$sp_dir"/pip-* 2>/dev/null || true
rm -rf "$sp_dir"/setuptools "$sp_dir"/setuptools-* "$sp_dir"/pkg_resources 2>/dev/null || true
rm -rf "$sp_dir"/_distutils_hack 2>/dev/null || true
# ── OpenCV ───────────────────────────────────────────────
local cv2_dir="$sp_dir/cv2"
if [ -d "$cv2_dir" ]; then
# Remove ffmpeg (28 MB on Windows), Haar cascades, dev files
rm -f "$cv2_dir"/opencv_videoio_ffmpeg*."$lib_suffix" 2>/dev/null || true
rm -rf "$cv2_dir/data" "$cv2_dir/gapi" "$cv2_dir/misc" "$cv2_dir/utils" 2>/dev/null || true
rm -rf "$cv2_dir/typing_stubs" "$cv2_dir/typing" 2>/dev/null || true
fi
# ── NumPy ────────────────────────────────────────────────
# Only strip modules that are safely unused by numpy's own import chain.
# DO NOT strip: lib, linalg, ma, matrixlib — numpy.__init__ imports them
# transitively (e.g. matrixlib → defmatrix → linalg), so removing any of
# these breaks `import numpy` itself, cascading into every downstream
# module. Learned the hard way in the v0.0.0.dev0 Windows build.
for mod in polynomial distutils f2py typing _pyinstaller; do
rm -rf "$sp_dir/numpy/$mod" 2>/dev/null || true
done
rm -rf "$sp_dir/numpy/tests" "$sp_dir/numpy/*/tests" 2>/dev/null || true
# ── Pillow (only used for system tray icon) ──────────────
rm -rf "$sp_dir/PIL/tests" 2>/dev/null || true
# Remove unused image format plugins (keep JPEG, PNG, ICO, BMP)
for plugin in Eps Gif Tiff Webp Psd Pcx Xbm Xpm Dds Ftex Gbr Grib \
Icns Im Imt Iptc McIrdas Mpo Msp Pcd Pixar Ppm Sgi \
Spider Sun Tga Wal Wmf; do
rm -f "$sp_dir/PIL/${plugin}ImagePlugin.py" 2>/dev/null || true
rm -f "$sp_dir/PIL/${plugin}ImagePlugin.pyc" 2>/dev/null || true
done
# ── zeroconf ─────────────────────────────────────────────
# DO NOT strip zeroconf/_services — the compiled Cython _listener.pyd
# imports from it, and the import fails at runtime with:
# ModuleNotFoundError: No module named 'zeroconf._services'
# Same class of bug as numpy — "presumed unused" submodule is actually
# imported internally by the package's own compiled code.
# ── Strip debug symbols ──────────────────────────────────
if command -v strip &>/dev/null; then
echo " Stripping debug symbols from .$ext_suffix files..."
find "$sp_dir" -name "*.$ext_suffix" -exec strip --strip-debug {} \; 2>/dev/null || true
fi
# ── Remove ledgrab if pip-installed ───────────────
rm -rf "$sp_dir"/ledgrab* "$sp_dir"/ledgrab*.dist-info 2>/dev/null || true
local cleaned_size
cleaned_size=$(du -sh "$sp_dir" | cut -f1)
echo " Site-packages after cleanup: $cleaned_size"
}
# ── Pre-compile .py → .pyc (keep sources) ────────────────────
#
# MUST run AFTER cleanup_site_packages. Speeds up first startup by
# producing .pyc alongside .py. We deliberately do NOT delete .py
# sources afterwards:
#
# 1. OpenCV's loader does literal file I/O on cv2/config.py (not
# an import) — stripping it breaks `import cv2` with:
# "OpenCV loader: missing configuration file: ['config.py']".
# 2. Other packages may do similar tricks (inspect.getsource,
# runtime introspection, __file__-relative data loading).
# 3. The size saving (~30%) isn't worth the whack-a-mole of
# shipping broken installers. We already hit this with
# numpy.linalg and zeroconf._services — enough incidents.
#
# Args:
# $1 — directory to compile (site-packages or app/src)
# $2 — python executable to use (default: python3)
compile_and_strip_sources() {
local target_dir="$1"
local py_cmd="${2:-python3}"
if [ ! -d "$target_dir" ]; then
return 0
fi
echo " Pre-compiling Python bytecode in $(basename "$target_dir")..."
"$py_cmd" -m compileall -b -q "$target_dir" 2>/dev/null || {
echo " ERROR: compileall failed for $target_dir — aborting"
return 1
}
# Drop __pycache__ to save the duplicated PEP-3147 copies; the
# `-b` flag above placed legacy .pyc next to each .py, so nothing
# of value is lost here.
find "$target_dir" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
}
# ── Import smoke test ────────────────────────────────────────
#
# Verifies that every top-level dependency that ledgrab actually
# uses can be imported from the stripped site-packages. Catches regressions
# where cleanup_site_packages removes a submodule that turns out to be
# imported internally by the package (e.g. numpy.linalg, zeroconf._services).
# Failing here is cheap; failing on a user's machine after install is not.
#
# Args:
# $1 — path to site-packages to test against
# $2 — python executable
# $3 — (optional) extra PYTHONPATH entry (e.g. app/src for ledgrab)
smoke_test_imports() {
local sp_dir="$1"
local py_cmd="${2:-python3}"
local extra_path="${3:-}"
echo " Running import smoke test..."
local pypath="$sp_dir"
if [ -n "$extra_path" ]; then
pypath="$extra_path:$sp_dir"
fi
# Modules that MUST import cleanly IF PRESENT. We don't enforce
# installation — Pillow for example is only a Windows dep. But if a
# module's top-level package dir exists in site-packages and we
# can't import it, that's a broken install and we abort.
local smoke_script
smoke_script=$(cat <<'PYEOF'
import importlib
import os
import sys
sp_dir = sys.argv[1]
# (module_name, site-packages path to check for presence)
candidates = [
('numpy', 'numpy'),
('numpy.linalg', 'numpy/linalg'),
('numpy.lib', 'numpy/lib'),
('numpy.matrixlib', 'numpy/matrixlib'),
('cv2', 'cv2'),
('fastapi', 'fastapi'),
('uvicorn', 'uvicorn'),
('starlette', 'starlette'),
('pydantic', 'pydantic'),
('zeroconf', 'zeroconf'),
('zeroconf._services', 'zeroconf/_services'),
('PIL', 'PIL'),
('PIL.Image', 'PIL'),
('yaml', 'yaml'),
]
tested = 0
skipped = 0
failed = []
for mod, path in candidates:
if not os.path.exists(os.path.join(sp_dir, path)):
skipped += 1
continue
try:
importlib.import_module(mod)
tested += 1
except Exception as e:
failed.append(f'{mod}: {type(e).__name__}: {e}')
if failed:
print('SMOKE TEST FAILED:', file=sys.stderr)
for f in failed:
print(f' {f}', file=sys.stderr)
sys.exit(1)
print(f' Smoke test passed ({tested} imported, {skipped} not installed)')
PYEOF
)
if ! PYTHONPATH="$pypath" "$py_cmd" -c "$smoke_script" "$sp_dir"; then
echo " ERROR: smoke test failed — site-packages is broken, aborting build"
return 1
fi
}
+437
View File
@@ -0,0 +1,437 @@
#!/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)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
BUILD_DIR="$SCRIPT_DIR"
DIST_NAME="LedGrab"
DIST_DIR="$BUILD_DIR/$DIST_NAME"
SERVER_DIR="$REPO_ROOT/server"
PYTHON_DIR="$DIST_DIR/python"
APP_DIR="$DIST_DIR/app"
PYTHON_VERSION="${PYTHON_VERSION:-3.11.9}"
source "$SCRIPT_DIR/build-common.sh"
# ── Version detection ────────────────────────────────────────
detect_version "${1:-}"
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 ────────────────────────────────────────────────────
echo "[1/9] Cleaning..."
clean_dist
# ── 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/9] 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/9] 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 ledgrab 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 the individual
# MSI packages from python.org (tcltk.msi + lib.msi) and extract them
# using msiextract (from msitools).
echo "[4/9] Bundling tkinter for screen overlay support..."
TK_EXTRACT="$BUILD_DIR/tk-extract"
rm -rf "$TK_EXTRACT"
mkdir -p "$TK_EXTRACT"
MSI_BASE="https://www.python.org/ftp/python/${PYTHON_VERSION}/amd64"
# Download tcltk.msi (contains _tkinter.pyd, tcl/tk DLLs, tcl8.6/, tk8.6/)
TCLTK_MSI="$BUILD_DIR/tcltk.msi"
if [ ! -f "$TCLTK_MSI" ]; then
curl -sL "$MSI_BASE/tcltk.msi" -o "$TCLTK_MSI"
fi
# Download lib.msi (contains tkinter/ Python package in the stdlib)
LIB_MSI="$BUILD_DIR/lib.msi"
if [ ! -f "$LIB_MSI" ]; then
curl -sL "$MSI_BASE/lib.msi" -o "$LIB_MSI"
fi
if command -v msiextract &>/dev/null; then
# Extract both MSIs
(cd "$TK_EXTRACT" && msiextract "$TCLTK_MSI" 2>/dev/null)
(cd "$TK_EXTRACT" && msiextract "$LIB_MSI" 2>/dev/null)
# Copy _tkinter.pyd
TKINTER_PYD=$(find "$TK_EXTRACT" -name "_tkinter.pyd" 2>/dev/null | head -1)
if [ -n "$TKINTER_PYD" ]; then
cp "$TKINTER_PYD" "$PYTHON_DIR/"
echo " Copied _tkinter.pyd"
else
echo " WARNING: _tkinter.pyd not found in tcltk.msi"
fi
# Copy Tcl/Tk DLLs
for dll in tcl86t.dll tk86t.dll; do
DLL_PATH=$(find "$TK_EXTRACT" -name "$dll" 2>/dev/null | head -1)
if [ -n "$DLL_PATH" ]; then
cp "$DLL_PATH" "$PYTHON_DIR/"
echo " Copied $dll"
fi
done
# Copy tkinter Python package
TKINTER_PKG=$(find "$TK_EXTRACT" -type d -name "tkinter" 2>/dev/null | head -1)
if [ -n "$TKINTER_PKG" ]; then
mkdir -p "$PYTHON_DIR/Lib"
cp -r "$TKINTER_PKG" "$PYTHON_DIR/Lib/tkinter"
echo " Copied tkinter/ package"
fi
# Copy tcl/tk data directories
for tcldir in tcl8.6 tk8.6; do
TCL_PATH=$(find "$TK_EXTRACT" -type d -name "$tcldir" 2>/dev/null | head -1)
if [ -n "$TCL_PATH" ]; then
cp -r "$TCL_PATH" "$PYTHON_DIR/$tcldir"
echo " Copied $tcldir/"
fi
done
echo " tkinter bundled successfully"
else
echo " WARNING: msiextract not found — skipping tkinter (install msitools)"
fi
# Add Lib to ._pth so tkinter package is importable
if ! grep -q '^Lib$' "$PTH_FILE"; then
echo 'Lib' >> "$PTH_FILE"
fi
rm -rf "$TK_EXTRACT"
# ── Download pip and install into embedded Python ────────────
echo "[5/9] 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 "[6/9] Downloading Windows dependencies..."
WHEEL_DIR="$BUILD_DIR/win-wheels"
mkdir -p "$WHEEL_DIR"
# Core dependencies (cross-platform, should have win_amd64 wheels)
DEPS=(
"fastapi>=0.115.0"
"uvicorn[standard]>=0.32.0"
"httpx>=0.27.2"
"mss>=9.0.2"
"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"
"opencv-python-headless>=4.8.0"
)
# Windows-only deps
WIN_DEPS=(
"PyAudioWPatch>=0.2.12"
"winrt-Windows.UI.Notifications>=3.0.0"
"winrt-Windows.UI.Notifications.Management>=3.0.0"
"winrt-Windows.Foundation>=3.0.0"
"winrt-Windows.Foundation.Collections>=3.0.0"
"winrt-Windows.ApplicationModel>=3.0.0"
# System tray (Pillow needed by pystray for tray icon)
"pystray>=0.19.0"
"Pillow>=10.4.0"
# Windows screen capture engines (mss is the only fallback without these)
"dxcam>=0.0.5"
"bettercam>=1.0.0"
"windows-capture>=1.5.0"
)
# 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
# ── Reduce package size ────────────────────────────────────────
cleanup_site_packages "$SITE_PACKAGES" "pyd" "dll"
# Windows-specific cleanup
rm -rf "$SITE_PACKAGES"/pythonwin 2>/dev/null || true
rm -f "$SITE_PACKAGES"/PyWin32.chm 2>/dev/null || true
find "$SITE_PACKAGES/winrt" -name "*.pyi" -delete 2>/dev/null || true
# Pre-compile and strip .py sources. MUST run AFTER cleanup (so we don't
# waste work compiling files about to be deleted). Uses host python —
# PYTHON_VERSION above must match the embedded Python major.minor or
# the generated .pyc will ImportError on the target.
compile_and_strip_sources "$SITE_PACKAGES" "python"
# Windows cross-build: host python can't load win_amd64 .pyd files, so
# we can't `import numpy` for real. Instead, check that the submodules
# known to be imported internally exist on disk — the same landmines that
# cost users a broken v0.0.0.dev0 installer.
echo " Verifying required submodules exist after cleanup..."
for required in \
"numpy/linalg" "numpy/lib" "numpy/matrixlib" "numpy/ma" \
"zeroconf/_services"; do
if [ ! -d "$SITE_PACKAGES/$required" ] && [ ! -f "$SITE_PACKAGES/$required.py" ] && [ ! -f "$SITE_PACKAGES/$required.pyc" ]; then
echo " ERROR: $required missing from site-packages — cleanup_site_packages removed something required. Aborting."
exit 1
fi
done
echo " All required submodules present."
WHEEL_COUNT=$(ls "$WHEEL_DIR"/*.whl 2>/dev/null | wc -l)
echo " Installed $WHEEL_COUNT packages"
# ── Build frontend ───────────────────────────────────────────
echo "[7/9] Building frontend..."
build_frontend
# ── Copy application files ───────────────────────────────────
echo "[8/9] Copying application files..."
copy_app_files
# Pre-compile app source for faster startup (keep .py too — app source
# is small and easier to debug in-place if a user reports an issue)
python -m compileall -b -q "$APP_DIR/src" 2>/dev/null || true
# ── Create launcher ──────────────────────────────────────────
echo "[8b/9] Creating launcher and packaging..."
cat > "$DIST_DIR/LedGrab.bat" << LAUNCHER
@echo off
cd /d "%~dp0"
:: Set paths
set PYTHONPATH=%~dp0app\src
set LEDGRAB_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 (tray icon handles UI and exit)
"%~dp0python\pythonw.exe" -m ledgrab
LAUNCHER
# Convert launcher to Windows line endings
sed -i 's/$/\r/' "$DIST_DIR/LedGrab.bat"
# Copy hidden launcher VBS
mkdir -p "$DIST_DIR/scripts"
cp "$SERVER_DIR/scripts/start-hidden.vbs" "$DIST_DIR/scripts/"
# ── 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)
# ── Build NSIS installer (if makensis is available) ──────────
SETUP_NAME="LedGrab-v${VERSION_CLEAN}-win-x64-setup.exe"
SETUP_PATH="$BUILD_DIR/$SETUP_NAME"
if command -v makensis &>/dev/null; then
echo "[9/9] Building NSIS installer..."
makensis -DVERSION="${VERSION_CLEAN}" "$SCRIPT_DIR/installer.nsi"
if [ -f "$SETUP_PATH" ]; then
SETUP_SIZE=$(du -h "$SETUP_PATH" | cut -f1)
echo " Installer: $SETUP_PATH ($SETUP_SIZE)"
else
echo " WARNING: makensis ran but installer not found at $SETUP_PATH"
fi
else
echo "[9/9] Skipping installer (makensis not found — install nsis to enable)"
fi
echo ""
echo "=== Build complete ==="
echo " ZIP: $ZIP_PATH ($ZIP_SIZE)"
if [ -f "$SETUP_PATH" ]; then
echo " Installer: $SETUP_PATH ($SETUP_SIZE)"
fi
echo ""
+249
View File
@@ -0,0 +1,249 @@
<#
.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
$RepoRoot = Split-Path $PSScriptRoot -Parent
$BuildDir = $PSScriptRoot
$DistName = "LedGrab"
$DistDir = Join-Path $BuildDir $DistName
$ServerDir = Join-Path $RepoRoot "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\ledgrab\__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"
}
# Embedded Python ._pth overrides PYTHONPATH, so add the app source path
# directly for ledgrab to be importable
if ($pthContent -notmatch '\.\.[/\\]app[/\\]src') {
$pthContent = $pthContent.TrimEnd() + "`n..\app\src`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 ledgrab package to avoid duplication
$sitePackages = Join-Path $PythonDir "Lib\site-packages"
Get-ChildItem -Path $sitePackages -Filter "ledgrab*" -Directory | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $sitePackages -Filter "ledgrab*.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
cd /d "%~dp0"
:: Set paths
set PYTHONPATH=%~dp0app\src
set LEDGRAB_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 (tray icon handles UI and exit)
"%~dp0python\pythonw.exe" -m ledgrab
'@
$launcherContent = $launcherContent -replace '%VERSION%', $VersionClean
$launcherPath = Join-Path $DistDir "LedGrab.bat"
Set-Content -Path $launcherPath -Value $launcherContent -Encoding ASCII
# Copy hidden launcher VBS
$scriptsDir = Join-Path $DistDir "scripts"
New-Item -ItemType Directory -Path $scriptsDir -Force | Out-Null
Copy-Item -Path (Join-Path $ServerDir "scripts\start-hidden.vbs") -Destination $scriptsDir
# ── 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 ""
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env bash
#
# Build a portable Linux distribution of LedGrab.
# Produces a self-contained tarball with virtualenv and launcher script.
#
# Usage:
# ./build-dist.sh [VERSION]
# ./build-dist.sh v0.1.0-alpha.1
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
BUILD_DIR="$SCRIPT_DIR"
DIST_NAME="LedGrab"
DIST_DIR="$BUILD_DIR/$DIST_NAME"
SERVER_DIR="$REPO_ROOT/server"
VENV_DIR="$DIST_DIR/venv"
APP_DIR="$DIST_DIR/app"
source "$SCRIPT_DIR/build-common.sh"
# ── Version detection ────────────────────────────────────────
detect_version "${1:-}"
TAR_NAME="LedGrab-v${VERSION_CLEAN}-linux-x64.tar.gz"
echo "=== Building LedGrab v${VERSION_CLEAN} (Linux) ==="
echo " Output: build/$TAR_NAME"
echo ""
# ── Clean ────────────────────────────────────────────────────
echo "[1/7] Cleaning..."
clean_dist
# ── Create virtualenv ────────────────────────────────────────
echo "[2/7] Creating virtualenv..."
python3 -m venv "$VENV_DIR"
source "$VENV_DIR/bin/activate"
pip install --upgrade pip --quiet
# ── Install dependencies ─────────────────────────────────────
echo "[3/7] Installing dependencies..."
pip install --quiet "${SERVER_DIR}[notifications]" 2>&1 | {
grep -i 'error\|failed' || true
}
# Resolve site-packages path (glob expand)
SITE_PACKAGES=$(echo "$VENV_DIR"/lib/python*/site-packages)
# Clean up with shared function
cleanup_site_packages "$SITE_PACKAGES" "so" "so"
# Pre-compile and strip .py sources (must happen AFTER cleanup)
compile_and_strip_sources "$SITE_PACKAGES" "python"
# Fail loud if cleanup broke any required import
smoke_test_imports "$SITE_PACKAGES" "python"
# ── Build frontend ───────────────────────────────────────────
echo "[4/7] Building frontend..."
build_frontend
# ── Copy application files ───────────────────────────────────
echo "[5/7] Copying application files..."
copy_app_files
# Pre-compile app source for faster startup (keep .py too — app source
# is small and easier to debug in-place if a user reports an issue)
python -m compileall -b -q "$APP_DIR/src" 2>/dev/null || true
# ── Create launcher ──────────────────────────────────────────
echo "[6/7] Creating launcher..."
cat > "$DIST_DIR/run.sh" << 'LAUNCHER'
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
export PYTHONPATH="$SCRIPT_DIR/app/src"
export LEDGRAB_CONFIG_PATH="$SCRIPT_DIR/app/config/default_config.yaml"
mkdir -p "$SCRIPT_DIR/data" "$SCRIPT_DIR/logs"
source "$SCRIPT_DIR/venv/bin/activate"
exec python -m ledgrab.main
LAUNCHER
sed -i "s/VERSION_PLACEHOLDER/${VERSION_CLEAN}/" "$DIST_DIR/run.sh"
chmod +x "$DIST_DIR/run.sh"
# ── Create autostart scripts ─────────────────────────────────
cat > "$DIST_DIR/install-service.sh" << 'SERVICE_INSTALL'
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SERVICE_NAME="ledgrab"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
RUN_SCRIPT="$SCRIPT_DIR/run.sh"
CURRENT_USER="$(whoami)"
if [ "$EUID" -ne 0 ] && [ "$CURRENT_USER" != "root" ]; then
echo "This script requires root privileges. Re-running with sudo..."
exec sudo "$0" "$@"
fi
# Resolve the actual user (not root) when run via sudo
ACTUAL_USER="${SUDO_USER:-$CURRENT_USER}"
ACTUAL_HOME=$(eval echo "~$ACTUAL_USER")
echo "Installing LedGrab systemd service..."
cat > "$SERVICE_FILE" << EOF
[Unit]
Description=LedGrab ambient lighting server
After=network.target
[Service]
Type=simple
User=$ACTUAL_USER
WorkingDirectory=$SCRIPT_DIR
ExecStart=$RUN_SCRIPT
Restart=on-failure
RestartSec=5
Environment=HOME=$ACTUAL_HOME
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "$SERVICE_NAME"
systemctl start "$SERVICE_NAME"
echo ""
echo " [OK] LedGrab service installed and started."
echo ""
echo " Commands:"
echo " sudo systemctl status $SERVICE_NAME # Check status"
echo " sudo systemctl stop $SERVICE_NAME # Stop"
echo " sudo systemctl restart $SERVICE_NAME # Restart"
echo " sudo journalctl -u $SERVICE_NAME -f # View logs"
echo ""
echo " To remove: run ./uninstall-service.sh"
SERVICE_INSTALL
chmod +x "$DIST_DIR/install-service.sh"
cat > "$DIST_DIR/uninstall-service.sh" << 'SERVICE_UNINSTALL'
#!/usr/bin/env bash
set -euo pipefail
SERVICE_NAME="ledgrab"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
if [ "$EUID" -ne 0 ] && [ "$(whoami)" != "root" ]; then
echo "This script requires root privileges. Re-running with sudo..."
exec sudo "$0" "$@"
fi
if [ ! -f "$SERVICE_FILE" ]; then
echo "LedGrab service is not installed."
exit 0
fi
echo "Removing LedGrab systemd service..."
systemctl stop "$SERVICE_NAME" 2>/dev/null || true
systemctl disable "$SERVICE_NAME" 2>/dev/null || true
rm -f "$SERVICE_FILE"
systemctl daemon-reload
echo ""
echo " [OK] LedGrab service removed."
SERVICE_UNINSTALL
chmod +x "$DIST_DIR/uninstall-service.sh"
# ── Create tarball ───────────────────────────────────────────
echo "[7/7] Creating $TAR_NAME..."
deactivate 2>/dev/null || true
TAR_PATH="$BUILD_DIR/$TAR_NAME"
(cd "$BUILD_DIR" && tar -czf "$TAR_NAME" "$DIST_NAME")
TAR_SIZE=$(du -h "$TAR_PATH" | cut -f1)
echo ""
echo "=== Build complete ==="
echo " Archive: $TAR_PATH"
echo " Size: $TAR_SIZE"
echo ""
+201
View File
@@ -0,0 +1,201 @@
; LedGrab NSIS Installer Script
; Cross-compilable on Linux: apt install nsis && makensis installer.nsi
;
; Expects the portable build to already exist at build/LedGrab/
; (run build-dist-windows.sh first)
!include "MUI2.nsh"
!include "FileFunc.nsh"
; ── Metadata ────────────────────────────────────────────────
!define APPNAME "LedGrab"
!define VBSNAME "start-hidden.vbs"
!define DESCRIPTION "Ambient lighting system — captures screen content and drives LED strips in real time"
!define VERSIONMAJOR 0
!define VERSIONMINOR 1
!define VERSIONBUILD 0
; Set from command line: makensis -DVERSION=0.1.0 installer.nsi
!ifndef VERSION
!define VERSION "${VERSIONMAJOR}.${VERSIONMINOR}.${VERSIONBUILD}"
!endif
Name "${APPNAME} v${VERSION}"
OutFile "${APPNAME}-v${VERSION}-win-x64-setup.exe"
InstallDir "$LOCALAPPDATA\${APPNAME}"
InstallDirRegKey HKCU "Software\${APPNAME}" "InstallDir"
RequestExecutionLevel user
SetCompressor /SOLID lzma
; ── Modern UI Configuration ─────────────────────────────────
!define MUI_ICON "..\server\src\ledgrab\static\icons\icon.ico"
!define MUI_UNICON "..\server\src\ledgrab\static\icons\icon.ico"
!define MUI_ABORTWARNING
; ── Pages ───────────────────────────────────────────────────
; Use MUI_FINISHPAGE_RUN_FUNCTION instead of MUI_FINISHPAGE_RUN_PARAMETERS —
; NSIS Exec command chokes on the quoting with RUN_PARAMETERS.
!define MUI_FINISHPAGE_RUN ""
!define MUI_FINISHPAGE_RUN_TEXT "Launch ${APPNAME}"
!define MUI_FINISHPAGE_RUN_FUNCTION LaunchApp
!insertmacro MUI_PAGE_WELCOME
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_COMPONENTS
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_LANGUAGE "English"
; ── Functions ─────────────────────────────────────────────
Function LaunchApp
ExecShell "open" "wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"'
Sleep 2000
ExecShell "open" "http://localhost:8080/"
FunctionEnd
; Detect running instance before install (file lock check on python.exe)
Function .onInit
IfFileExists "$INSTDIR\python\python.exe" 0 done
ClearErrors
FileOpen $0 "$INSTDIR\python\python.exe" a
IfErrors locked
FileClose $0
Goto done
locked:
MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION \
"${APPNAME} is currently running.$\n$\nYes = Stop and continue$\nNo = Continue anyway (may cause errors)$\nCancel = Abort" \
IDYES kill IDNO done
Abort
kill:
nsExec::ExecToLog 'wmic process where "ExecutablePath like $\'%${APPNAME}%python%$\'" call terminate'
Sleep 2000
done:
FunctionEnd
; ── Installer Sections ──────────────────────────────────────
Section "!${APPNAME} (required)" SecCore
SectionIn RO
SetOutPath "$INSTDIR"
; Wipe prior payload dirs before extracting. NSIS File /r MERGES files
; on top of existing ones — on an upgrade, stale .pyc/.pyd from the old
; version (and any files removed or renamed since) would survive,
; producing a half-old/half-new install that presents as "version
; mismatch" or "duplicate package" ImportErrors at runtime.
; IMPORTANT: only touch payload dirs — never $INSTDIR\data or
; $INSTDIR\logs (user config must be preserved across upgrades).
RMDir /r "$INSTDIR\python"
RMDir /r "$INSTDIR\app"
RMDir /r "$INSTDIR\scripts"
Delete "$INSTDIR\LedGrab.bat"
; Copy the entire portable build
File /r "LedGrab\python"
File /r "LedGrab\app"
File /r "LedGrab\scripts"
File "LedGrab\LedGrab.bat"
; Create data and logs directories
CreateDirectory "$INSTDIR\data"
CreateDirectory "$INSTDIR\logs"
; Create uninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
; Start Menu shortcuts
CreateDirectory "$SMPROGRAMS\${APPNAME}"
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$INSTDIR\app\src\ledgrab\static\icons\icon.ico" 0
CreateShortcut "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" "$INSTDIR\uninstall.exe"
; Registry: install location + Add/Remove Programs entry
WriteRegStr HKCU "Software\${APPNAME}" "InstallDir" "$INSTDIR"
WriteRegStr HKCU "Software\${APPNAME}" "Version" "${VERSION}"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"DisplayName" "${APPNAME}"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"DisplayVersion" "${VERSION}"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"UninstallString" '"$INSTDIR\uninstall.exe"'
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"InstallLocation" "$INSTDIR"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"DisplayIcon" "$INSTDIR\app\src\ledgrab\static\icons\icon.ico"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"Publisher" "Alexei Dolgolyov"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"URLInfoAbout" "https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab"
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"NoModify" 1
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"NoRepair" 1
; Calculate installed size for Add/Remove Programs
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"EstimatedSize" "$0"
SectionEnd
Section "Desktop shortcut" SecDesktop
CreateShortcut "$DESKTOP\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$INSTDIR\app\src\ledgrab\static\icons\icon.ico" 0
SectionEnd
Section "Start with Windows" SecAutostart
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$INSTDIR\app\src\ledgrab\static\icons\icon.ico" 0
SectionEnd
; ── Section Descriptions ────────────────────────────────────
!insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN
!insertmacro MUI_DESCRIPTION_TEXT ${SecCore} \
"Install ${APPNAME} server and all required files."
!insertmacro MUI_DESCRIPTION_TEXT ${SecDesktop} \
"Create a shortcut on your desktop."
!insertmacro MUI_DESCRIPTION_TEXT ${SecAutostart} \
"Start ${APPNAME} automatically when you log in."
!insertmacro MUI_FUNCTION_DESCRIPTION_END
; ── Uninstaller ─────────────────────────────────────────────
Section "Uninstall"
; Remove shortcuts
Delete "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk"
Delete "$SMPROGRAMS\${APPNAME}\Uninstall.lnk"
RMDir "$SMPROGRAMS\${APPNAME}"
Delete "$DESKTOP\${APPNAME}.lnk"
Delete "$SMSTARTUP\${APPNAME}.lnk"
; Remove application files (but NOT data/ — preserve user config)
RMDir /r "$INSTDIR\python"
RMDir /r "$INSTDIR\app"
RMDir /r "$INSTDIR\scripts"
Delete "$INSTDIR\LedGrab.bat"
Delete "$INSTDIR\uninstall.exe"
; Remove logs (but keep data/)
RMDir /r "$INSTDIR\logs"
; Try to remove install dir (only succeeds if empty — data/ may remain)
RMDir "$INSTDIR"
; Remove registry keys
DeleteRegKey HKCU "Software\${APPNAME}"
DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}"
SectionEnd