diff --git a/.github/workflows/windows-installer.yml b/.github/workflows/windows-installer.yml new file mode 100644 index 00000000..6904c37c --- /dev/null +++ b/.github/workflows/windows-installer.yml @@ -0,0 +1,187 @@ +name: Build Windows Installer + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Version number for the installer' + required: false + default: '4.0' + +jobs: + build-windows-installer: + runs-on: windows-latest + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup MSYS2 + uses: msys2/setup-msys2@v2 + with: + msystem: MSYS + update: true + install: >- + base-devel + gcc + make + cmake + pkg-config + python + python-pip + python-setuptools + git + sqlite3 + + - name: Cache MSYS2 packages + uses: actions/cache@v4 + with: + path: D:\a\_temp\msys64\var\cache\pacman\pkg + key: msys2-packages-${{ runner.os }}-${{ hashFiles('.github/workflows/windows-installer.yml') }} + restore-keys: | + msys2-packages-${{ runner.os }}- + + - name: Install OpenPLC Runtime using install.sh + shell: msys2 {0} + run: | + echo "==========================================" + echo "Installing OpenPLC Runtime via install.sh" + echo "==========================================" + + # Run the install script which handles all dependencies and build + ./install.sh + + echo "Installation complete!" + + - name: Prepare installer payload + shell: pwsh + run: | + Write-Host "Preparing installer payload..." + + # Create payload directory structure + New-Item -ItemType Directory -Force -Path "windows\payload\msys64" + New-Item -ItemType Directory -Force -Path "windows\payload\openplc-runtime" + + # Copy MSYS2 installation + Write-Host "Copying MSYS2 installation (this may take a while)..." + $msys2Path = "D:\a\_temp\msys64" + if (Test-Path $msys2Path) { + Copy-Item -Path "$msys2Path\*" -Destination "windows\payload\msys64" -Recurse -Force + } else { + Write-Host "MSYS2 path not found at $msys2Path, trying C:\msys64..." + Copy-Item -Path "C:\msys64\*" -Destination "windows\payload\msys64" -Recurse -Force + } + + # Copy OpenPLC Runtime (excluding unnecessary files) + Write-Host "Copying OpenPLC Runtime..." + $excludeDirs = @('.git', '.github', '.mypy_cache', '.ruff_cache', '.vscode', 'tests', '__pycache__') + + Get-ChildItem -Path "." -Exclude $excludeDirs | Where-Object { + $_.Name -ne "windows" -and $_.Name -ne ".git" + } | ForEach-Object { + if ($_.PSIsContainer) { + Copy-Item -Path $_.FullName -Destination "windows\payload\openplc-runtime\$($_.Name)" -Recurse -Force + } else { + Copy-Item -Path $_.FullName -Destination "windows\payload\openplc-runtime\" -Force + } + } + + # Clean up to reduce size + Write-Host "Cleaning up payload to reduce size..." + + # Remove pacman cache + $pacmanCache = "windows\payload\msys64\var\cache\pacman\pkg" + if (Test-Path $pacmanCache) { + Remove-Item -Path "$pacmanCache\*" -Force -Recurse -ErrorAction SilentlyContinue + } + + # Remove unnecessary MSYS2 directories + $msys2Cleanup = @( + "windows\payload\msys64\var\log", + "windows\payload\msys64\tmp" + ) + foreach ($dir in $msys2Cleanup) { + if (Test-Path $dir) { + Remove-Item -Path "$dir\*" -Force -Recurse -ErrorAction SilentlyContinue + } + } + + # Calculate payload size + $payloadSize = (Get-ChildItem -Path "windows\payload" -Recurse | Measure-Object -Property Length -Sum).Sum / 1MB + Write-Host "Payload size: $([math]::Round($payloadSize, 2)) MB" + + - name: Install Inno Setup + shell: pwsh + run: | + Write-Host "Installing Inno Setup..." + choco install innosetup -y --no-progress + + # Add Inno Setup to PATH + $env:PATH = "C:\Program Files (x86)\Inno Setup 6;$env:PATH" + [Environment]::SetEnvironmentVariable("PATH", $env:PATH, "Process") + + - name: Create placeholder icon + shell: pwsh + run: | + # Create a simple placeholder icon if one doesn't exist + # In production, you should include a proper icon file + if (-not (Test-Path "windows\icon.ico")) { + Write-Host "Creating placeholder icon..." + # Download a generic icon or create one + # For now, we'll modify the setup.iss to not require an icon + } + + - name: Build installer with Inno Setup + shell: pwsh + run: | + Write-Host "Building installer..." + + # Set version from input or tag + $version = "${{ github.event.inputs.version }}" + if ([string]::IsNullOrEmpty($version)) { + $version = "${{ github.ref_name }}".TrimStart('v') + if ([string]::IsNullOrEmpty($version) -or $version -eq "${{ github.ref_name }}") { + $version = "4.0" + } + } + Write-Host "Building version: $version" + + # Create output directory + New-Item -ItemType Directory -Force -Path "windows\output" + + # Run Inno Setup compiler + & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" ` + /DMyAppVersion="$version" ` + /O"windows\output" ` + /F"OpenPLC_Runtime_$($version)_Setup" ` + "windows\setup.iss" + + if ($LASTEXITCODE -ne 0) { + Write-Error "Inno Setup compilation failed with exit code $LASTEXITCODE" + exit 1 + } + + Write-Host "Installer built successfully!" + Get-ChildItem "windows\output" + + - name: Upload installer artifact + uses: actions/upload-artifact@v4 + with: + name: windows-installer + path: windows/output/*.exe + retention-days: 30 + + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v1 + with: + files: windows/output/*.exe + draft: false + prerelease: ${{ contains(github.ref, 'beta') || contains(github.ref, 'alpha') }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/core/src/CMakeLists.txt b/core/src/CMakeLists.txt index 078914de..ba00b5ba 100644 --- a/core/src/CMakeLists.txt +++ b/core/src/CMakeLists.txt @@ -19,6 +19,13 @@ add_compile_options(-Wall -Werror -Wextra -fstack-protector-strong -D_FORTIFY_SOURCE=2 -O2 -Wformat -Werror=format-security -fPIC -fPIE) +# On Cygwin/MSYS2, disable treating warnings as errors due to header conflicts +# The _POSIX_C_SOURCE redefinition warning is caused by Python headers vs system headers +# Detection: CMAKE_SYSTEM_NAME is "CYGWIN" or "MSYS" on these platforms +if(CMAKE_SYSTEM_NAME MATCHES "CYGWIN|MSYS") + # Remove -Werror but keep all other warnings and specific -Werror=format-security + add_compile_options(-Wno-error) +endif() # Step 3: Build the executable and link against the shared library add_executable(plc_main @@ -37,9 +44,9 @@ add_executable(plc_main ) # Link against shared library -target_link_libraries(plc_main - dl - pthread +target_link_libraries(plc_main + dl + pthread ${PYTHON_LIBRARIES} ) diff --git a/core/src/drivers/plugin_driver.c b/core/src/drivers/plugin_driver.c index b278bab7..03e81bd9 100644 --- a/core/src/drivers/plugin_driver.c +++ b/core/src/drivers/plugin_driver.c @@ -1,6 +1,18 @@ #define PY_SSIZE_T_CLEAN + +// Suppress _POSIX_C_SOURCE redefinition warning from Python.h on MSYS2/Cygwin +// Python.h defines _POSIX_C_SOURCE to 200809L which conflicts with system headers +#if defined(__GNUC__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wcpp" +#endif + #include +#if defined(__GNUC__) +#pragma GCC diagnostic pop +#endif + #include "../plc_app/image_tables.h" #include "../plc_app/utils/log.h" #include "plugin_config.h" @@ -41,10 +53,12 @@ plugin_driver_t *plugin_driver_create(void) return NULL; } +#if !defined(__CYGWIN__) && !defined(__MSYS__) // Initialize mutex with priority inheritance to prevent priority inversion // This ensures that when a lower-priority plugin thread holds the mutex, // it temporarily inherits the priority of any higher-priority thread // (like the PLC scan cycle thread) waiting for the mutex. + // Note: Priority inheritance is not available on MSYS2/Cygwin pthread_mutexattr_t mutex_attr; if (pthread_mutexattr_init(&mutex_attr) != 0) { @@ -67,6 +81,14 @@ plugin_driver_t *plugin_driver_create(void) } pthread_mutexattr_destroy(&mutex_attr); +#else + // On MSYS2/Cygwin, use a regular mutex without priority inheritance + if (pthread_mutex_init(&driver->buffer_mutex, NULL) != 0) + { + free(driver); + return NULL; + } +#endif return driver; } diff --git a/core/src/drivers/python_plugin_bridge.h b/core/src/drivers/python_plugin_bridge.h index b0442e0f..7ccba5e4 100644 --- a/core/src/drivers/python_plugin_bridge.h +++ b/core/src/drivers/python_plugin_bridge.h @@ -2,8 +2,20 @@ #define __PYTHON_PLUGIN_BRIDGE_H #define PY_SSIZE_T_CLEAN + +// Suppress _POSIX_C_SOURCE redefinition warning from Python.h on MSYS2/Cygwin +// Python.h defines _POSIX_C_SOURCE to 200809L which conflicts with system headers +#if defined(__GNUC__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wcpp" +#endif + #include +#if defined(__GNUC__) +#pragma GCC diagnostic pop +#endif + // Forward declaration struct plugin_instance_s; diff --git a/core/src/plc_app/scan_cycle_manager.c b/core/src/plc_app/scan_cycle_manager.c index 55b92ee0..6a300256 100644 --- a/core/src/plc_app/scan_cycle_manager.c +++ b/core/src/plc_app/scan_cycle_manager.c @@ -8,6 +8,13 @@ #include "scan_cycle_manager.h" #include "utils/utils.h" +// CLOCK_MONOTONIC_RAW is Linux-specific, use CLOCK_MONOTONIC on other platforms +#if defined(__CYGWIN__) || defined(__MSYS__) || !defined(CLOCK_MONOTONIC_RAW) +#define OPENPLC_CLOCK CLOCK_MONOTONIC +#else +#define OPENPLC_CLOCK CLOCK_MONOTONIC_RAW +#endif + static uint64_t expected_start_us = 0; static uint64_t last_start_us = 0; static pthread_mutex_t stats_mutex = PTHREAD_MUTEX_INITIALIZER; @@ -23,7 +30,7 @@ plc_timing_stats_t plc_timing_stats = {.scan_time_min = INT64_MAX, static uint64_t ts_now_us(void) { struct timespec ts; - clock_gettime(CLOCK_MONOTONIC_RAW, &ts); + clock_gettime(OPENPLC_CLOCK, &ts); return (uint64_t)ts.tv_sec * 1000000ull + ts.tv_nsec / 1000; } diff --git a/core/src/plc_app/utils/utils.c b/core/src/plc_app/utils/utils.c index d01586cc..4ea6c5f1 100644 --- a/core/src/plc_app/utils/utils.c +++ b/core/src/plc_app/utils/utils.c @@ -2,28 +2,53 @@ #include #include #include -#include #include +// MSYS2/Cygwin does not support mlockall or real-time scheduling +// These features are only available on Linux +#if !defined(__CYGWIN__) && !defined(__MSYS__) +#include +#define HAS_REALTIME_FEATURES 1 +#else +#define HAS_REALTIME_FEATURES 0 +#endif + unsigned long long *ext_common_ticktime__ = NULL; -unsigned long tick__ = 0; -char *ext_plc_program_md5 = NULL; +unsigned long tick__ = 0; +char *ext_plc_program_md5 = NULL; -void normalize_timespec(struct timespec *ts) +void normalize_timespec(struct timespec *ts) { - while (ts->tv_nsec >= 1e9) + while (ts->tv_nsec >= 1e9) { ts->tv_nsec -= 1e9; ts->tv_sec++; } } -void sleep_until(struct timespec *ts) +void sleep_until(struct timespec *ts) { +#if HAS_REALTIME_FEATURES clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, ts, NULL); +#else + // Fallback for MSYS2/Cygwin: use nanosleep (relative sleep) + struct timespec now, remaining; + clock_gettime(CLOCK_MONOTONIC, &now); + remaining.tv_sec = ts->tv_sec - now.tv_sec; + remaining.tv_nsec = ts->tv_nsec - now.tv_nsec; + if (remaining.tv_nsec < 0) + { + remaining.tv_sec--; + remaining.tv_nsec += 1000000000L; + } + if (remaining.tv_sec >= 0) + { + nanosleep(&remaining, NULL); + } +#endif } -void timespec_diff(struct timespec *a, struct timespec *b, struct timespec *result) +void timespec_diff(struct timespec *a, struct timespec *b, struct timespec *result) { // Calculate the difference in seconds result->tv_sec = a->tv_sec - b->tv_sec; @@ -32,7 +57,7 @@ void timespec_diff(struct timespec *a, struct timespec *b, struct timespec *resu result->tv_nsec = a->tv_nsec - b->tv_nsec; // Handle borrowing if nanoseconds are negative - if (result->tv_nsec < 0) + if (result->tv_nsec < 0) { // Borrow 1 second (1e9 nanoseconds) --result->tv_sec; @@ -41,24 +66,30 @@ void timespec_diff(struct timespec *a, struct timespec *b, struct timespec *resu } // configure SCHED_FIFO priority -void set_realtime_priority(void) +void set_realtime_priority(void) { +#if HAS_REALTIME_FEATURES struct sched_param param; param.sched_priority = 20; // Priority between 1 and 99 - if (sched_setscheduler(0, SCHED_FIFO, ¶m) != 0) + if (sched_setscheduler(0, SCHED_FIFO, ¶m) != 0) { log_error("sched_setscheduler failed: %s", strerror(errno)); - } - else + } + else { log_info("Scheduler set to SCHED_FIFO, priority %d", param.sched_priority); } +#else + // Real-time scheduling not available on MSYS2/Cygwin + log_info("Real-time scheduling not available on this platform"); +#endif } // Lock all memory pages to prevent page faults during PLC execution void lock_memory(void) { +#if HAS_REALTIME_FEATURES if (mlockall(MCL_CURRENT | MCL_FUTURE) != 0) { log_error("mlockall failed: %s", strerror(errno)); @@ -67,11 +98,15 @@ void lock_memory(void) { log_info("Memory locked successfully (MCL_CURRENT | MCL_FUTURE)"); } +#else + // Memory locking not available on MSYS2/Cygwin + log_info("Memory locking not available on this platform"); +#endif } size_t parse_hex_string(const char *hex_string, uint8_t *data) { - size_t count = 0; + size_t count = 0; const char *ptr = hex_string; while (*ptr != '\0') @@ -107,7 +142,8 @@ size_t parse_hex_string(const char *hex_string, uint8_t *data) return count; } -void bytes_to_hex_string(const uint8_t *bytes, size_t len, char *out_str, size_t out_size, const char *prepend) +void bytes_to_hex_string(const uint8_t *bytes, size_t len, char *out_str, size_t out_size, + const char *prepend) { size_t pos = 0; @@ -135,7 +171,7 @@ void bytes_to_hex_string(const uint8_t *bytes, size_t len, char *out_str, size_t if (written < 0 || (size_t)written >= out_size - pos) { // Stop if buffer is full or error - break; + break; } pos += written; @@ -147,7 +183,7 @@ void bytes_to_hex_string(const uint8_t *bytes, size_t len, char *out_str, size_t break; } out_str[pos++] = ' '; - out_str[pos] = '\0'; + out_str[pos] = '\0'; } } @@ -161,4 +197,3 @@ void bytes_to_hex_string(const uint8_t *bytes, size_t len, char *out_str, size_t out_str[out_size - 1] = '\0'; } } - diff --git a/install.sh b/install.sh index 4e468cdb..7f117937 100755 --- a/install.sh +++ b/install.sh @@ -1,9 +1,25 @@ #!/bin/bash set -e -# Check for root privileges -check_root() +# Detect if running on MSYS2/MinGW/Cygwin (Windows) +is_msys2() { + case "$(uname -s)" in + MSYS*|MINGW*|CYGWIN*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +# Check for root privileges (skip on MSYS2/Windows) +check_root() { + if is_msys2; then + # Root is not required/meaningful on MSYS2 + return 0 + fi if [[ $EUID -ne 0 ]]; then echo "ERROR: This script must be run as root" >&2 echo "Example: sudo ./install.sh" >&2 @@ -11,7 +27,7 @@ check_root() fi } -# Make sure we are root before proceeding +# Make sure we are root before proceeding (unless on MSYS2) check_root # Detect the project root directory @@ -52,8 +68,15 @@ echo "OpenPLC Runtime Installation" echo "Project directory: $OPENPLC_DIR" echo "Working directory: $(pwd)" -install_dependencies() +install_dependencies() { + # Check for MSYS2 first (before trying to source /etc/os-release) + if is_msys2; then + echo "Platform: MSYS2/Windows" + install_deps_msys2 + return $? + fi + source /etc/os-release echo "Distro: $ID" @@ -86,7 +109,7 @@ install_dependencies() } # For Ubuntu/Debian -install_deps_apt() { +install_deps_apt() { apt-get update && \ apt-get install -y --no-install-recommends \ build-essential \ @@ -114,46 +137,65 @@ install_deps_dnf() { && dnf clean all } +# For MSYS2 on Windows +install_deps_msys2() { + echo "Installing dependencies via pacman..." + # Update package database (but don't do full system upgrade to avoid breaking frozen bundles) + pacman -Sy --noconfirm + # Install required packages + pacman -S --noconfirm --needed \ + base-devel \ + gcc \ + make \ + cmake \ + pkg-config \ + python \ + python-pip \ + python-setuptools \ + git \ + sqlite3 +} + compile_plc() { echo "Preparing build directory..." - + # Always clean build directory for Docker environment or when CMake cache exists # This prevents cross-contamination between Linux and Docker builds if [ -d "$OPENPLC_DIR/build" ] && [ -f "$OPENPLC_DIR/build/CMakeCache.txt" ]; then echo "Cleaning existing build directory to ensure clean build..." rm -rf "$OPENPLC_DIR/build" fi - + # Create build directory if ! mkdir -p "$OPENPLC_DIR/build"; then echo "ERROR: Failed to create build directory" >&2 return 1 fi - + cd "$OPENPLC_DIR/build" || { echo "ERROR: Failed to change to build directory" >&2 return 1 } - + echo "Running cmake configuration..." if ! cmake ..; then echo "ERROR: CMake configuration failed" >&2 cd "$OPENPLC_DIR" return 1 fi - + echo "Compiling with make (using $(nproc) cores)..." if ! make -j"$(nproc)"; then echo "ERROR: Compilation failed" >&2 cd "$OPENPLC_DIR" return 1 fi - + cd "$OPENPLC_DIR" || { echo "ERROR: Failed to return to main directory" >&2 return 1 } - + echo "SUCCESS: OpenPLC compiled successfully!" return 0 } @@ -187,28 +229,28 @@ setup_plugin_venvs() { plugins_with_requirements+=("$plugin_name") log_info "Found plugin with requirements: $plugin_name" done < <(find "$plugins_dir" -name "requirements.txt" -type f -print0) - + # If no plugins found, return if [ ${#plugins_with_requirements[@]} -eq 0 ]; then log_info "No plugins with requirements.txt found" return 0 fi - + log_info "Found ${#plugins_with_requirements[@]} plugin(s) that need virtual environments" - + # Create virtual environments for each plugin for plugin_name in "${plugins_with_requirements[@]}"; do local venv_path="$OPENPLC_DIR/venvs/$plugin_name" local requirements_file="$plugins_dir/$plugin_name/requirements.txt" - + if [ -d "$venv_path" ]; then log_info "Virtual environment already exists for $plugin_name" - + # Check if requirements.txt is newer than the venv (dependencies may have changed) if [ "$requirements_file" -nt "$venv_path" ]; then log_warning "Requirements file is newer than venv for $plugin_name" log_info "Updating dependencies for $plugin_name..." - + if bash "$manage_script" install "$plugin_name"; then log_success "Dependencies updated for $plugin_name" else @@ -220,7 +262,7 @@ setup_plugin_venvs() { fi else log_info "Creating virtual environment for plugin: $plugin_name" - + if bash "$manage_script" create "$plugin_name"; then log_success "Virtual environment created for $plugin_name" else @@ -229,14 +271,20 @@ setup_plugin_venvs() { fi fi done - + log_success "All plugin virtual environments are ready" return 0 } # Setup runtime directory (needed for both Linux and Docker) -mkdir -p /var/run/runtime -chmod 775 /var/run/runtime 2>/dev/null || true # Ignore permission errors in Docker +# On MSYS2, use /run/runtime which maps to the MSYS2 installation directory +if is_msys2; then + mkdir -p /run/runtime 2>/dev/null || true + chmod 775 /run/runtime 2>/dev/null || true +else + mkdir -p /var/run/runtime + chmod 775 /var/run/runtime 2>/dev/null || true # Ignore permission errors in Docker +fi # Make scripts executable chmod +x "$OPENPLC_DIR/install.sh" 2>/dev/null || true diff --git a/requirements.txt b/requirements.txt index 898a574e..1dd17d45 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,4 @@ python-dotenv pytest pytest-flask pre-commit -psutil +psutil; sys_platform != "cygwin" diff --git a/start_openplc.sh b/start_openplc.sh index 69fb8623..c4227268 100755 --- a/start_openplc.sh +++ b/start_openplc.sh @@ -9,8 +9,24 @@ VENV_DIR="$OPENPLC_DIR/venvs/runtime" # Ensure we're in the project directory cd "$OPENPLC_DIR" -check_root() +# Detect if running on MSYS2/MinGW/Cygwin (Windows) +is_msys2() { + case "$(uname -s)" in + MSYS*|MINGW*|CYGWIN*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +check_root() { + if is_msys2; then + # Root is not required/meaningful on MSYS2 + return 0 + fi if [[ $EUID -ne 0 ]]; then echo "ERROR: This script must be run as root" >&2 echo "Example: sudo ./start_openplc.sh" >&2 @@ -23,7 +39,11 @@ check_installation() if [ ! -f "$OPENPLC_DIR/.installed" ]; then echo "ERROR: OpenPLC Runtime v4 is not installed." >&2 echo "Please run the install script first:" >&2 - echo " sudo ./install.sh" >&2 + if is_msys2; then + echo " ./install.sh" >&2 + else + echo " sudo ./install.sh" >&2 + fi exit 1 fi } @@ -82,27 +102,27 @@ setup_runtime_venv() { get_enabled_plugins() { local plugins_conf="$OPENPLC_DIR/plugins.conf" local enabled_plugins=() - + if [ ! -f "$plugins_conf" ]; then log_warning "plugins.conf not found: $plugins_conf" return 0 fi - + while IFS= read -r line; do # Skip empty lines and comments [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue - + # Parse the line: name,path,enabled,type,config_path,venv_path IFS=',' read -ra FIELDS <<< "$line" local plugin_name="${FIELDS[0]}" local enabled="${FIELDS[2]}" - + # Check if plugin is enabled (1) if [[ "$enabled" == "1" ]]; then enabled_plugins+=("$plugin_name") fi done < "$plugins_conf" - + printf '%s\n' "${enabled_plugins[@]}" } @@ -135,28 +155,28 @@ setup_plugin_venvs() { plugins_with_requirements+=("$plugin_name") log_info "Found plugin with requirements: $plugin_name" done < <(find "$plugins_dir" -name "requirements.txt" -type f -print0) - + # If no plugins found, return if [ ${#plugins_with_requirements[@]} -eq 0 ]; then log_info "No plugins with requirements.txt found" return 0 fi - + log_info "Found ${#plugins_with_requirements[@]} plugin(s) that need virtual environments" - + # Create virtual environments for each plugin for plugin_name in "${plugins_with_requirements[@]}"; do local venv_path="$OPENPLC_DIR/venvs/$plugin_name" local requirements_file="$plugins_dir/$plugin_name/requirements.txt" - + if [ -d "$venv_path" ]; then log_info "Virtual environment already exists for $plugin_name" - + # Check if requirements.txt is newer than the venv (dependencies may have changed) if [ "$requirements_file" -nt "$venv_path" ]; then log_warning "Requirements file is newer than venv for $plugin_name" log_info "Updating dependencies for $plugin_name..." - + if bash "$manage_script" install "$plugin_name"; then log_success "Dependencies updated for $plugin_name" else @@ -168,7 +188,7 @@ setup_plugin_venvs() { fi else log_info "Creating virtual environment for plugin: $plugin_name" - + if bash "$manage_script" create "$plugin_name"; then log_success "Virtual environment created for $plugin_name" else @@ -177,7 +197,7 @@ setup_plugin_venvs() { fi fi done - + log_success "All plugin virtual environments are ready" return 0 } diff --git a/webserver/app.py b/webserver/app.py index 3948d387..59e54424 100644 --- a/webserver/app.py +++ b/webserver/app.py @@ -1,5 +1,7 @@ +import errno import json import os +import platform import shutil import ssl import threading @@ -258,6 +260,24 @@ def run_https(): # logger.error("Error creating database tables: %s", e) pass + # On non-Linux platforms (MSYS2/Cygwin), patch Python SSL recv socket + # to handle EAGAIN/EWOULDBLOCK errors that cause "Resource temporarily unavailable" + is_linux = platform.system() == "Linux" + if not is_linux: + print(f"Non-Linux platform detected ({platform.system()}). Patching recv socket...") + _orig_recv = ssl.SSLSocket.recv + + def _patched_recv(self, buflen, flags=0): + try: + return _orig_recv(self, buflen, flags) + except BlockingIOError as e: + # Only swallow EAGAIN / EWOULDBLOCK (errno 11) - re-raise other errors + if getattr(e, "errno", None) in (errno.EAGAIN, errno.EWOULDBLOCK, 11): + return b"" + raise + + ssl.SSLSocket.recv = _patched_recv + try: cert_gen = CertGen(hostname=HOSTNAME, ip_addresses=["127.0.0.1"]) diff --git a/webserver/runtimemanager.py b/webserver/runtimemanager.py index bcee5a32..659d37c0 100644 --- a/webserver/runtimemanager.py +++ b/webserver/runtimemanager.py @@ -4,7 +4,14 @@ import threading import time -import psutil +# psutil is optional - not available on MSYS2/Cygwin platforms +try: + import psutil + + HAS_PSUTIL = True +except ImportError: + psutil = None + HAS_PSUTIL = False from webserver.logger import get_logger from webserver.unixclient import SyncUnixClient @@ -12,6 +19,10 @@ logger, buffer = get_logger("logger", use_buffer=True) +# Log once if psutil is not available +if not HAS_PSUTIL: + logger.info("psutil not available - process detection features disabled") + class RuntimeManager: def __init__(self, runtime_path, plc_socket, log_socket): @@ -26,8 +37,13 @@ def __init__(self, runtime_path, plc_socket, log_socket): def find_running_process(self): """ - Find the running PLC runtime process + Find the running PLC runtime process. + Returns None if psutil is not available (MSYS2/Cygwin). """ + if not HAS_PSUTIL: + # Cannot detect existing processes without psutil + return None + # Find the running PLC runtime process by executable path for proc in psutil.process_iter(["pid", "exe", "cmdline"]): try: @@ -133,7 +149,7 @@ def is_runtime_alive(self): """ if self.process is None: return False - if isinstance(self.process, psutil.Process): + if HAS_PSUTIL and isinstance(self.process, psutil.Process): if self.process.is_running() and self.process.status() != psutil.STATUS_ZOMBIE: return True elif isinstance(self.process, subprocess.Popen): @@ -183,7 +199,7 @@ def stop(self): self.monitor_thread.join(timeout=5) time.sleep(1) if self.process: - if isinstance(self.process, psutil.Process): + if HAS_PSUTIL and isinstance(self.process, psutil.Process): self.process.terminate() try: self.process.wait(timeout=5) diff --git a/windows/README.md b/windows/README.md new file mode 100644 index 00000000..6d14554a --- /dev/null +++ b/windows/README.md @@ -0,0 +1,110 @@ +# OpenPLC Runtime - Windows Installer + +This directory contains the infrastructure for building a Windows installer for OpenPLC Runtime using MSYS2 and Inno Setup. + +## Overview + +The Windows installer bundles a complete MSYS2 environment with all required dependencies (GCC, Python, etc.) so users can run OpenPLC Runtime on Windows without needing to install any additional software. + +## Files + +- `setup.iss` - Inno Setup script that creates the Windows installer +- `StartOpenPLC.bat` - Windows launcher script that starts the runtime inside MSYS2 +- `provision-msys2.sh` - Script to install packages and configure MSYS2 (used during CI build) + +## Building the Installer + +### Automated Build (GitHub Actions) + +The installer is automatically built by GitHub Actions when: +- A tag starting with `v` is pushed (e.g., `v4.0.0`) +- The workflow is manually triggered via `workflow_dispatch` + +The workflow: +1. Sets up MSYS2 on a Windows runner +2. Installs all required packages (GCC, Python, CMake, etc.) +3. Builds the OpenPLC Runtime +4. Creates a Python virtual environment with all dependencies +5. Packages everything into an Inno Setup installer +6. Uploads the installer to GitHub Releases (for tag pushes) + +### Manual Build + +To build the installer manually on a Windows machine: + +1. Install MSYS2 from https://www.msys2.org/ +2. Open MSYS2 MSYS terminal and run: + ```bash + pacman -Syu --noconfirm + pacman -S --noconfirm base-devel gcc make cmake pkg-config python python-pip git sqlite3 + ``` +3. Clone and build OpenPLC Runtime: + ```bash + git clone https://github.com/Autonomy-Logic/openplc-runtime.git + cd openplc-runtime + python3 -m venv venvs/runtime + ./venvs/runtime/bin/python3 -m pip install -r requirements.txt + ./venvs/runtime/bin/python3 -m pip install -e . + mkdir build && cd build && cmake .. && make + ``` +4. Install Inno Setup from https://jrsoftware.org/isinfo.php +5. Create the payload directory structure: + ``` + windows/ + payload/ + msys64/ <- Copy your MSYS2 installation here + openplc-runtime/ <- Copy the runtime files here + ``` +6. Run Inno Setup compiler: + ``` + "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" windows\setup.iss + ``` + +## Installation + +The installer creates a per-user installation (no admin rights required) at: +``` +%LOCALAPPDATA%\OpenPLC Runtime\ +``` + +This includes: +- `msys64/` - Complete MSYS2 environment +- `openplc-runtime/` - OpenPLC Runtime files +- `StartOpenPLC.bat` - Launcher script + +## Usage + +After installation, users can: +1. Use the Start Menu shortcut "Start OpenPLC Runtime" +2. Or run `StartOpenPLC.bat` directly + +The runtime will start and be accessible at https://localhost:8443 + +## Size Considerations + +The installer is large (~500MB-1GB compressed) because it includes: +- Complete MSYS2 environment +- GCC toolchain (needed for compiling PLC programs) +- Python with all dependencies +- OpenPLC Runtime + +To reduce size, the build process: +- Cleans pacman package cache +- Removes unnecessary log files +- Excludes test files and development tools + +## Troubleshooting + +### Runtime fails to start +- Ensure the installation path does not contain spaces +- Try running as administrator if permission issues occur +- Check that antivirus is not blocking MSYS2 executables + +### Compilation errors +- The GCC toolchain is bundled with the installer +- If compilation fails, check that the build directory exists + +### Socket errors +- The runtime uses Unix domain sockets via MSYS2 +- Ensure no other instance is running +- Check that the `/run/runtime` directory exists in MSYS2 diff --git a/windows/StartOpenPLC.bat b/windows/StartOpenPLC.bat new file mode 100644 index 00000000..529f0904 --- /dev/null +++ b/windows/StartOpenPLC.bat @@ -0,0 +1,57 @@ +@echo off +REM OpenPLC Runtime - Windows Launcher +REM This script starts the OpenPLC Runtime inside the MSYS2 environment + +setlocal EnableDelayedExpansion + +REM Get the directory where this script is located +set "SCRIPT_DIR=%~dp0" +set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%" + +REM Set MSYS2 root directory (relative to this script) +set "MSYS2_ROOT=%SCRIPT_DIR%\msys64" + +REM Check if MSYS2 exists +if not exist "%MSYS2_ROOT%\usr\bin\bash.exe" ( + echo ERROR: MSYS2 installation not found at %MSYS2_ROOT% + echo Please reinstall OpenPLC Runtime. + pause + exit /b 1 +) + +REM Set environment variables for MSYS2 +set "CHERE_INVOKING=1" +set "MSYSTEM=MSYS" +set "HOME=/home/openplc" + +REM Convert Windows path to MSYS2 path +set "OPENPLC_WIN_PATH=%SCRIPT_DIR%\openplc-runtime" +set "OPENPLC_MSYS_PATH=%OPENPLC_WIN_PATH:\=/%" +set "OPENPLC_MSYS_PATH=%OPENPLC_MSYS_PATH:C:=/c%" +set "OPENPLC_MSYS_PATH=%OPENPLC_MSYS_PATH:D:=/d%" +set "OPENPLC_MSYS_PATH=%OPENPLC_MSYS_PATH:E:=/e%" + +echo ========================================== +echo OpenPLC Runtime for Windows +echo ========================================== +echo. +echo Starting OpenPLC Runtime... +echo MSYS2 Root: %MSYS2_ROOT% +echo OpenPLC Dir: %OPENPLC_WIN_PATH% +echo. + +REM Create runtime directory if it doesn't exist +if not exist "%MSYS2_ROOT%\run\runtime" ( + mkdir "%MSYS2_ROOT%\run\runtime" 2>nul +) + +REM Start the OpenPLC Runtime +"%MSYS2_ROOT%\usr\bin\bash.exe" -lc "cd '%OPENPLC_MSYS_PATH%' && ./venvs/runtime/bin/python3 -m webserver.app" + +if %ERRORLEVEL% neq 0 ( + echo. + echo ERROR: OpenPLC Runtime exited with error code %ERRORLEVEL% + pause +) + +endlocal diff --git a/windows/provision-msys2.sh b/windows/provision-msys2.sh new file mode 100644 index 00000000..bd2ab643 --- /dev/null +++ b/windows/provision-msys2.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# OpenPLC Runtime - MSYS2 Provisioning Script +# This script is run inside MSYS2 to install all required packages and dependencies +# for the OpenPLC Runtime Windows distribution. +# +# This script simply calls the main install.sh script which handles all +# MSYS2-specific installation and configuration. + +set -e + +echo "==========================================" +echo "OpenPLC Runtime - MSYS2 Provisioning" +echo "==========================================" + +# Get the OpenPLC directory (parent of windows folder) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OPENPLC_DIR="$(dirname "$SCRIPT_DIR")" + +echo "OpenPLC Directory: $OPENPLC_DIR" + +# Run the main install script which handles MSYS2 detection and installation +cd "$OPENPLC_DIR" +./install.sh + +# Clean up to reduce size for the installer payload +echo "Cleaning up to reduce size..." +pacman -Scc --noconfirm || true +rm -rf /var/cache/pacman/pkg/* 2>/dev/null || true +rm -rf /var/log/* 2>/dev/null || true +rm -rf /tmp/* 2>/dev/null || true + +echo "==========================================" +echo "OpenPLC Runtime provisioning complete!" +echo "==========================================" diff --git a/windows/setup.iss b/windows/setup.iss new file mode 100644 index 00000000..55ee6ec8 --- /dev/null +++ b/windows/setup.iss @@ -0,0 +1,104 @@ +; OpenPLC Runtime - Inno Setup Script +; This script creates a Windows installer that bundles MSYS2 with all dependencies + +#define MyAppName "OpenPLC Runtime" +#define MyAppVersion "4.0" +#define MyAppPublisher "Autonomy Logic" +#define MyAppURL "https://autonomylogic.com" +#define MyAppExeName "StartOpenPLC.bat" + +[Setup] +; Application information +AppId={{A1B2C3D4-E5F6-7890-ABCD-EF1234567890} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL} + +; Installation settings +DefaultDirName={localappdata}\OpenPLC Runtime +DefaultGroupName={#MyAppName} +DisableProgramGroupPage=yes + +; Per-user installation (no admin required) +PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog + +; Output settings +OutputDir=output +OutputBaseFilename=OpenPLC_Runtime_Setup + +; Compression settings (LZMA2 for best compression of large files) +Compression=lzma2/ultra64 +SolidCompression=yes +LZMAUseSeparateProcess=yes +LZMANumBlockThreads=4 + +; UI settings +WizardStyle=modern +WizardSizePercent=120 + +; Disk spanning for large installers (optional) +; DiskSpanning=yes + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Files] +; Bundle the entire MSYS2 installation +Source: "payload\msys64\*"; DestDir: "{app}\msys64"; Flags: ignoreversion recursesubdirs createallsubdirs + +; Bundle the OpenPLC Runtime +Source: "payload\openplc-runtime\*"; DestDir: "{app}\openplc-runtime"; Flags: ignoreversion recursesubdirs createallsubdirs + +; Launcher and support files +Source: "StartOpenPLC.bat"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +; Start Menu shortcuts (per-user, no admin needed) +Name: "{userprograms}\{#MyAppName}\Start OpenPLC Runtime"; Filename: "{app}\{#MyAppExeName}"; WorkingDir: "{app}" +Name: "{userprograms}\{#MyAppName}\MSYS2 Terminal"; Filename: "{app}\msys64\msys2.exe"; WorkingDir: "{app}\msys64" +Name: "{userprograms}\{#MyAppName}\Uninstall {#MyAppName}"; Filename: "{uninstallexe}" + +; Desktop shortcut (optional) +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; WorkingDir: "{app}"; Tasks: desktopicon + +[Run] +; Option to start OpenPLC after installation +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent shellexec + +[UninstallDelete] +; Clean up runtime directories on uninstall +Type: filesandordirs; Name: "{app}\msys64\run\runtime" +Type: filesandordirs; Name: "{app}\openplc-runtime\build" +Type: filesandordirs; Name: "{app}\openplc-runtime\venvs" +Type: filesandordirs; Name: "{app}\openplc-runtime\*.pyc" +Type: filesandordirs; Name: "{app}\openplc-runtime\__pycache__" + +[Code] +// Custom code for installation + +function InitializeSetup(): Boolean; +begin + Result := True; + // Add any pre-installation checks here +end; + +procedure CurStepChanged(CurStep: TSetupStep); +var + RuntimeDir: String; +begin + if CurStep = ssPostInstall then + begin + // Create runtime directory after installation + RuntimeDir := ExpandConstant('{app}\msys64\run\runtime'); + if not DirExists(RuntimeDir) then + CreateDir(RuntimeDir); + end; +end;