diff --git a/.github/actions/build-linux-deb/action.yml b/.github/actions/build-linux-deb/action.yml new file mode 100644 index 0000000000..c716aa5bb8 --- /dev/null +++ b/.github/actions/build-linux-deb/action.yml @@ -0,0 +1,185 @@ +name: 'Build Linux .deb Package' +description: 'Build Lemonade .deb package for Linux (minimal or full with Electron)' +inputs: + include-electron: + description: 'Whether to include the Electron app (full build)' + required: false + default: 'false' +outputs: + package-name: + description: 'The name of the generated .deb package file' + value: ${{ steps.build-deb.outputs.package_name }} +runs: + using: "composite" + steps: + - name: Install build dependencies + shell: bash + run: | + set -e + + echo "Updating package lists..." + sudo apt-get update + + echo "Installing build dependencies..." + sudo apt-get install -y \ + build-essential \ + cmake \ + pkg-config \ + libcurl4-openssl-dev \ + libssl-dev \ + zlib1g-dev \ + git + + echo "Verifying installations..." + cmake --version + gcc --version + pkg-config --version + + echo "Build dependencies installed successfully!" + + - name: Setup Node.js + if: inputs.include-electron == 'true' + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Build Electron App for Linux + if: inputs.include-electron == 'true' + shell: bash + run: | + set -e + + echo "Building Electron app for Linux..." + + cd src/app + + # Install dependencies + echo "Installing npm dependencies..." + npm install + + # Build the Electron app for Linux + echo "Building Electron app for Linux..." + npm run build:linux + + # List what was created + echo "Contents of dist-app:" + ls -la dist-app/ || echo "dist-app directory not found" + + echo "Contents of linux-unpacked:" + ls -la dist-app/linux-unpacked/ || echo "linux-unpacked directory not found" + + # Verify the build output exists (check both uppercase and lowercase) + if [ -f "dist-app/linux-unpacked/lemonade" ]; then + echo "Found: dist-app/linux-unpacked/lemonade" + elif [ -f "dist-app/linux-unpacked/Lemonade" ]; then + echo "Found: dist-app/linux-unpacked/Lemonade" + else + echo "ERROR: Electron app executable not found!" + echo "Looking for 'lemonade' or 'Lemonade' in dist-app/linux-unpacked/" + exit 1 + fi + + echo "Electron app build successful!" + + - name: Build C++ Server with CMake + shell: bash + run: | + set -e + + echo "Building lemonade-router and lemonade-server for Linux..." + + cd src/cpp + + # Create build directory + if [ -d "build" ]; then + echo "Removing existing build directory..." + rm -rf build + fi + mkdir build + cd build + + # Configure with explicit Release build type + echo "Configuring CMake..." + if [ "${{ inputs.include-electron }}" == "true" ]; then + cmake .. -DCMAKE_BUILD_TYPE=Release -DINCLUDE_ELECTRON_APP=ON + else + cmake .. -DCMAKE_BUILD_TYPE=Release + fi + + # Build using all available cores + echo "Building binaries..." + cmake --build . -j$(nproc) + + # Verify binaries exist + if [ ! -f "lemonade-router" ]; then + echo "ERROR: lemonade-router not found!" + echo "Build directory contents:" + ls -lh + exit 1 + fi + + if [ ! -f "lemonade-server" ]; then + echo "ERROR: lemonade-server not found!" + echo "Build directory contents:" + ls -lh + exit 1 + fi + + echo "✓ Binaries found successfully" + + echo "Verifying binary executability..." + ./lemonade-router --version + ./lemonade-server --version + + echo "C++ build successful!" + + - name: Build .deb package + id: build-deb + shell: bash + run: | + set -e + + cd src/cpp/build + + echo "Creating .deb package with CPack..." + cpack -G DEB -V + + # List generated files + echo "Files in build directory:" + ls -lh *.deb 2>/dev/null || echo "No .deb files found in current directory" + + # Determine expected package name based on build type + if [ "${{ inputs.include-electron }}" == "true" ]; then + DEB_FILE="lemonade_${LEMONADE_VERSION}_amd64.deb" + PACKAGE_TYPE="full" + else + DEB_FILE="lemonade-server-minimal_${LEMONADE_VERSION}_amd64.deb" + PACKAGE_TYPE="minimal" + fi + + echo "Looking for: $DEB_FILE" + + if [ ! -f "$DEB_FILE" ]; then + echo "ERROR: .deb package not created!" + echo "Contents of build directory:" + ls -lR . + exit 1 + fi + + echo ".deb package ($PACKAGE_TYPE) created successfully!" + ls -lh "$DEB_FILE" + + # Show package info + echo "Package information:" + dpkg-deb --info "$DEB_FILE" + + echo "Package contents:" + if [ "${{ inputs.include-electron }}" == "true" ]; then + dpkg-deb --contents "$DEB_FILE" | head -100 + else + dpkg-deb --contents "$DEB_FILE" + fi + + # Output the package name for subsequent steps + echo "package_name=$DEB_FILE" >> $GITHUB_OUTPUT + diff --git a/.github/workflows/cpp_server_build_test_release.yml b/.github/workflows/cpp_server_build_test_release.yml index 7b58ee8bfb..3d83a76e8e 100644 --- a/.github/workflows/cpp_server_build_test_release.yml +++ b/.github/workflows/cpp_server_build_test_release.yml @@ -159,6 +159,44 @@ jobs: Write-Host "C++ build successful!" -ForegroundColor Green + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Build Electron App + shell: PowerShell + run: | + $ErrorActionPreference = "Stop" + + Write-Host "Building Electron app..." -ForegroundColor Cyan + + cd src\app + + # Install dependencies + Write-Host "Installing npm dependencies..." -ForegroundColor Yellow + npm install + if ($LASTEXITCODE -ne 0) { + Write-Host "ERROR: npm install failed!" -ForegroundColor Red + exit $LASTEXITCODE + } + + # Build the Electron app for Windows + Write-Host "Building Electron app for Windows..." -ForegroundColor Yellow + npm run build:win + if ($LASTEXITCODE -ne 0) { + Write-Host "ERROR: Electron app build failed!" -ForegroundColor Red + exit $LASTEXITCODE + } + + # Verify the build output exists + if (-not (Test-Path "dist-app\win-unpacked\Lemonade.exe")) { + Write-Host "ERROR: Electron app executable not found!" -ForegroundColor Red + exit 1 + } + + Write-Host "Electron app build successful!" -ForegroundColor Green + - name: Build the Lemonade Server Installer shell: PowerShell run: | @@ -169,19 +207,26 @@ jobs: # Run the build installer script .\build_installer.ps1 - # Verify installer was created + # Verify installers were created if (-not (Test-Path "lemonade-server-minimal.msi")) { - Write-Host "ERROR: Installer not created!" -ForegroundColor Red + Write-Host "ERROR: Minimal installer not created!" -ForegroundColor Red + exit 1 + } + + if (-not (Test-Path "lemonade.msi")) { + Write-Host "ERROR: Full installer not created!" -ForegroundColor Red exit 1 } - Write-Host "Installer created successfully!" -ForegroundColor Green + Write-Host "Installers created successfully!" -ForegroundColor Green - - name: Upload Lemonade Server Installer + - name: Upload Lemonade Server Installers uses: actions/upload-artifact@v4 with: name: Lemonade_Server_MSI - path: src\cpp\lemonade-server-minimal.msi + path: | + src\cpp\lemonade-server-minimal.msi + src\cpp\lemonade.msi retention-days: 7 build-ryzenai-server: @@ -323,110 +368,10 @@ jobs: id: get_version uses: ./.github/actions/get-version - - name: Install build dependencies - run: | - set -e - - echo "Updating package lists..." - sudo apt-get update - - echo "Installing build dependencies..." - sudo apt-get install -y \ - build-essential \ - cmake \ - pkg-config \ - libcurl4-openssl-dev \ - libssl-dev \ - zlib1g-dev \ - git - - echo "Verifying installations..." - cmake --version - gcc --version - pkg-config --version - - echo "Build dependencies installed successfully!" - - - name: Build C++ Server with CMake - run: | - set -e - - echo "Building lemonade-router and lemonade-server for Linux..." - - cd src/cpp - - # Create build directory - if [ -d "build" ]; then - echo "Removing existing build directory..." - rm -rf build - fi - mkdir build - cd build - - # Configure with explicit Release build type - echo "Configuring CMake..." - cmake .. -DCMAKE_BUILD_TYPE=Release - - # Build using all available cores - echo "Building binaries..." - cmake --build . -j$(nproc) - - # Verify binaries exist - if [ ! -f "lemonade-router" ]; then - echo "ERROR: lemonade-router not found!" - echo "Build directory contents:" - ls -lh - exit 1 - fi - - if [ ! -f "lemonade-server" ]; then - echo "ERROR: lemonade-server not found!" - echo "Build directory contents:" - ls -lh - exit 1 - fi - - echo "✓ Binaries found successfully" - - echo "Verifying binary executability..." - ./lemonade-router --version - ./lemonade-server --version - - echo "C++ build successful!" - - - name: Build .deb package - run: | - set -e - - cd src/cpp/build - - echo "Creating .deb package with CPack..." - cpack -G DEB -V - - # List generated files - echo "Files in build directory:" - ls -lh *.deb 2>/dev/null || echo "No .deb files found in current directory" - - # Verify package was created (using dynamic version) - DEB_FILE="lemonade-server-minimal_${LEMONADE_VERSION}_amd64.deb" - echo "Looking for: $DEB_FILE" - - if [ ! -f "$DEB_FILE" ]; then - echo "ERROR: .deb package not created!" - echo "Contents of build directory:" - ls -lR . - exit 1 - fi - - echo ".deb package created successfully!" - ls -lh "$DEB_FILE" - - # Show package info - echo "Package information:" - dpkg-deb --info "$DEB_FILE" - - echo "Package contents:" - dpkg-deb --contents "$DEB_FILE" + - name: Build Linux .deb package (minimal) + uses: ./.github/actions/build-linux-deb + with: + include-electron: 'false' - name: Upload .deb package uses: actions/upload-artifact@v4 @@ -435,6 +380,33 @@ jobs: path: src/cpp/build/lemonade-server-minimal_${{ env.LEMONADE_VERSION }}_amd64.deb retention-days: 7 + build-lemonade-deb-full: + name: Build Lemonade Full .deb Package (with Electron App) + runs-on: ubuntu-latest + outputs: + version: ${{ steps.get_version.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + clean: true + fetch-depth: 0 + + - name: Get version from CMakeLists.txt + id: get_version + uses: ./.github/actions/get-version + + - name: Build Linux .deb package (full with Electron) + uses: ./.github/actions/build-linux-deb + with: + include-electron: 'true' + + - name: Upload full .deb package + uses: actions/upload-artifact@v4 + with: + name: lemonade-deb-full + path: src/cpp/build/lemonade_${{ env.LEMONADE_VERSION }}_amd64.deb + retention-days: 7 + # ======================================================================== # TEST JOBS - Run on stx-halo + Windows workers # ======================================================================== @@ -1642,6 +1614,7 @@ jobs: needs: - build-lemonade-server-installer - build-lemonade-server-deb + - build-lemonade-deb-full - build-ryzenai-server - test-exe-rai-hybrid-basics - test-exe-flm @@ -1662,12 +1635,18 @@ jobs: name: Lemonade_Server_MSI path: . - - name: Download Lemonade Server .deb Package (Linux) + - name: Download Lemonade Server .deb Package (Linux - Minimal) uses: actions/download-artifact@v4 with: name: lemonade-server-deb path: . + - name: Download Lemonade Full .deb Package (Linux - with Electron App) + uses: actions/download-artifact@v4 + with: + name: lemonade-deb-full + path: . + - name: Download RyzenAI Server Package uses: actions/download-artifact@v4 with: @@ -1685,7 +1664,9 @@ jobs: run: | echo "Release artifacts:" ls -lh lemonade-server-minimal.msi + ls -lh lemonade.msi ls -lh lemonade-server-minimal_${LEMONADE_VERSION}_amd64.deb + ls -lh lemonade_${LEMONADE_VERSION}_amd64.deb ls -lh ryzenai-server.zip - name: Create Release @@ -1693,7 +1674,9 @@ jobs: with: files: | lemonade-server-minimal.msi + lemonade.msi lemonade-server-minimal_${{ env.LEMONADE_VERSION }}_amd64.deb + lemonade_${{ env.LEMONADE_VERSION }}_amd64.deb ryzenai-server.zip fail_on_unmatched_files: true diff --git a/.gitignore b/.gitignore index a6dc2fee50..94279ed65c 100644 --- a/.gitignore +++ b/.gitignore @@ -177,6 +177,11 @@ CLAUDE.* .cursor/ .cursor** +# Electron app +src/app/node_modules/ +src/app/dist/ +src/app/dist-app/ +src/app/.webpack/ # WiX Toolset build artifacts *.msi -*.wixpdb \ No newline at end of file +*.wixpdb diff --git a/README.md b/README.md index 5d3e456654..31d93c28dc 100644 --- a/README.md +++ b/README.md @@ -222,4 +222,3 @@ This project is: - diff --git a/src/app/README.md b/src/app/README.md new file mode 100644 index 0000000000..7d6dc1ae64 --- /dev/null +++ b/src/app/README.md @@ -0,0 +1,85 @@ +# Lemonade Desktop App + +A desktop GUI for interacting with the Lemonade Server. + +## Overview + +This app provides a native desktop experience for managing models and chatting with LLMs running on `lemonade-router`. It connects to the server via HTTP API and offers a modern, resizable panel-based interface. + +**Key Features:** +- Model management (list, pull, load/unload) +- Chat interface with markdown/code rendering and LaTeX support +- Real-time server log viewer +- Persistent layout and inference settings +- Custom frameless window with zoom controls + +## Code Structure + +``` +src/app/ +├── main.js # Electron main process +├── preload.js # IPC bridge (contextIsolation) +├── webpack.config.js # Bundler config +├── package.json # Dependencies and build scripts +│ +├── src/ +│ └── renderer/ # React UI (TypeScript) +│ ├── App.tsx # Root component, layout orchestration +│ ├── TitleBar.tsx # Custom window controls +│ ├── ModelManager.tsx # Model list and actions +│ ├── ChatWindow.tsx # LLM chat interface +│ ├── LogsWindow.tsx # Server log viewer +│ ├── CenterPanel.tsx # Welcome/info panel +│ ├── SettingsModal.tsx # Inference parameters +│ └── utils/ # API helpers and config +``` + +``` + +## Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ Electron Main Process (main.js) │ +│ Window management, IPC, settings │ +├─────────────────────────────────────────────────┤ +│ Preload Script (contextIsolation) │ +│ Exposes safe IPC bridge to renderer │ +├─────────────────────────────────────────────────┤ +│ React Renderer (TypeScript) │ +│ UI components, state management │ +├─────────────────────────────────────────────────┤ +│ HTTP API │ +│ Communicates with lemonade-router │ +└─────────────────────────────────────────────────┘ +``` + +## Prerequisites + +- **Node.js** v18 or higher +- **npm** (comes with Node.js) + +## Building + +```bash +cd src/app + +# Install dependencies +npm install + +# Development (with DevTools) +npm run dev + +# Production build +npm run build +``` + +## Development Scripts + +```bash +npm start # Build and run +npm run dev # Build and run with DevTools +npm run build # Production build (creates installer) +npm run build:renderer # Build renderer only (webpack) +npm run watch:renderer # Watch mode for renderer +``` diff --git a/src/app/package.json b/src/app/package.json index b6a905743e..8c74e6d6fb 100644 --- a/src/app/package.json +++ b/src/app/package.json @@ -64,10 +64,18 @@ "from": "dist/renderer", "to": "dist/renderer", "filter": [ - "**/*" + "**/*", + "!**/*.map" ] - } + }, + "!node_modules", + "!src", + "!**/*.ts", + "!**/*.tsx", + "!**/*.map" ], + "extraResources": [], + "asarUnpack": [], "win": { "icon": "../../docs/assets/favicon.ico", "target": [ @@ -79,8 +87,8 @@ } ] }, + "electronLanguages": ["en-US"], "mac": { - "icon": "../../docs/assets/favicon.ico", "target": [ "dmg" ], diff --git a/src/app/src/renderer/ModelManager.tsx b/src/app/src/renderer/ModelManager.tsx index 4e3f9f2848..17e484cc07 100644 --- a/src/app/src/renderer/ModelManager.tsx +++ b/src/app/src/renderer/ModelManager.tsx @@ -84,6 +84,7 @@ const ModelManager: React.FC = ({ isVisible, width = 280 }) = setSupportedModelsData(data); const suggestedModels = Object.entries(data) .filter(([name, info]) => info.suggested || name.startsWith(USER_MODEL_PREFIX)) + .filter(([name, info]) => info.recipe !== 'whispercpp') .map(([name, info]) => ({ name, info })) .sort((a, b) => a.name.localeCompare(b.name)); setModels(suggestedModels); diff --git a/src/app/webpack.config.js b/src/app/webpack.config.js index 3efbbe78b4..10f07e8b80 100644 --- a/src/app/webpack.config.js +++ b/src/app/webpack.config.js @@ -1,11 +1,11 @@ const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); -module.exports = { - mode: 'development', +module.exports = (env, argv) => ({ + mode: argv.mode || 'development', entry: './src/renderer/index.tsx', target: 'electron-renderer', - devtool: 'source-map', + devtool: argv.mode === 'production' ? false : 'source-map', module: { rules: [ { @@ -49,5 +49,4 @@ module.exports = { filename: 'index.html', }), ], -}; - +}); diff --git a/src/cpp/CMakeLists.txt b/src/cpp/CMakeLists.txt index 704e5a52d6..7f324a0d29 100644 --- a/src/cpp/CMakeLists.txt +++ b/src/cpp/CMakeLists.txt @@ -1,5 +1,30 @@ cmake_minimum_required(VERSION 3.20) -project(lemon_cpp VERSION 9.0.8) + +# Use static runtime library on Windows to avoid DLL dependencies +if(MSVC) + set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") +endif() + +project(lemon_cpp VERSION 9.1.0) + +# ============================================================ +# Electron source paths (used by both runtime and installers) +# ============================================================ +get_filename_component(ELECTRON_APP_SOURCE_DIR "${CMAKE_SOURCE_DIR}/../../src/app" ABSOLUTE) +set(ELECTRON_APP_BUILD_DIR "${ELECTRON_APP_SOURCE_DIR}/dist-app") +if(WIN32) + set(ELECTRON_APP_UNPACKED_DIR "${ELECTRON_APP_BUILD_DIR}/win-unpacked") + set(ELECTRON_EXE_NAME "Lemonade.exe") +elseif(APPLE) + set(ELECTRON_APP_UNPACKED_DIR "${ELECTRON_APP_BUILD_DIR}/mac") + set(ELECTRON_EXE_NAME "Lemonade.app") +else() + set(ELECTRON_APP_UNPACKED_DIR "${ELECTRON_APP_BUILD_DIR}/linux-unpacked") + # Electron builder uses lowercase product name on Linux + set(ELECTRON_EXE_NAME "lemonade") +endif() + +# C++ Standard set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -177,11 +202,14 @@ if(WIN32) @ONLY ) - # Find WiX Toolset 5.0+ (unified 'wix' command) + set(WIX_PRODUCT_WXS "${CMAKE_CURRENT_BINARY_DIR}/installer/Product.wxs") + set(WIX_FRAGMENT_SCRIPT "${CMAKE_CURRENT_SOURCE_DIR}/installer/generate_electron_fragment.py") + + # Find WiX Toolset 5.0+ (unified 'wix' command) and Python for fragment generation find_program(WIX_EXECUTABLE wix) + find_package(Python3 COMPONENTS Interpreter) if(WIX_EXECUTABLE) - # Get WiX version execute_process( COMMAND ${WIX_EXECUTABLE} --version OUTPUT_VARIABLE WIX_VERSION_OUTPUT @@ -190,28 +218,72 @@ if(WIN32) message(STATUS "WiX Toolset found: ${WIX_EXECUTABLE}") message(STATUS "WiX version: ${WIX_VERSION_OUTPUT}") - # Create custom target for building MSI installer using WiX 5.0 - add_custom_target(wix_installer - COMMAND ${CMAKE_COMMAND} -E echo "Building WiX MSI installer with WiX 5.0..." - - # WiX 5.0 uses 'wix build' command - single file, simple approach + file(TO_NATIVE_PATH "${CMAKE_CURRENT_SOURCE_DIR}" WIX_SOURCE_DIR_NATIVE) + file(TO_NATIVE_PATH "${WIX_PRODUCT_WXS}" WIX_PRODUCT_WXS_NATIVE) + file(TO_NATIVE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/lemonade-server-minimal.msi" WIX_MINIMAL_OUTPUT_NATIVE) + file(TO_NATIVE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/lemonade.msi" WIX_FULL_OUTPUT_NATIVE) + file(TO_NATIVE_PATH "${ELECTRON_APP_UNPACKED_DIR}" WIX_ELECTRON_SOURCE_NATIVE) + set(WIX_ELECTRON_FRAGMENT "${CMAKE_CURRENT_BINARY_DIR}/installer/ElectronAppFragment.wxs") + file(TO_NATIVE_PATH "${WIX_ELECTRON_FRAGMENT}" WIX_ELECTRON_FRAGMENT_NATIVE) + + # Minimal installer (server only) + add_custom_target(wix_installer_minimal + COMMAND ${CMAKE_COMMAND} -E echo "Building WiX MSI installer (server only)..." COMMAND ${WIX_EXECUTABLE} build -arch x64 -ext WixToolset.UI.wixext - -d SourceDir="${CMAKE_CURRENT_SOURCE_DIR}" - -out "${CMAKE_CURRENT_SOURCE_DIR}/lemonade-server-minimal.msi" - "${CMAKE_CURRENT_BINARY_DIR}/installer/Product.wxs" - + -d SourceDir="${WIX_SOURCE_DIR_NATIVE}" + -d IncludeElectron=0 + -out "${WIX_MINIMAL_OUTPUT_NATIVE}" + "${WIX_PRODUCT_WXS_NATIVE}" COMMAND ${CMAKE_COMMAND} -E echo "MSI installer created: lemonade-server-minimal.msi" - DEPENDS ${EXECUTABLE_NAME} WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} - COMMENT "Building WiX MSI installer" + COMMENT "Building WiX MSI installer (server only)" + ) + + set(WIX_INSTALLER_TARGETS wix_installer_minimal) + + if(Python3_FOUND) + add_custom_target(wix_installer_full + COMMAND ${CMAKE_COMMAND} -E echo "Building WiX MSI installer (with Electron app)..." + COMMAND ${Python3_EXECUTABLE} ${WIX_FRAGMENT_SCRIPT} + --source "${ELECTRON_APP_UNPACKED_DIR}" + --output "${WIX_ELECTRON_FRAGMENT}" + --component-group "ElectronAppComponents" + --root-id "ElectronAppDir" + --path-variable "ElectronSourceDir" + COMMAND ${WIX_EXECUTABLE} build + -arch x64 + -ext WixToolset.UI.wixext + -d SourceDir="${WIX_SOURCE_DIR_NATIVE}" + -d IncludeElectron=1 + -d ElectronSourceDir="${WIX_ELECTRON_SOURCE_NATIVE}" + -out "${WIX_FULL_OUTPUT_NATIVE}" + "${WIX_PRODUCT_WXS_NATIVE}" + "${WIX_ELECTRON_FRAGMENT_NATIVE}" + COMMAND ${CMAKE_COMMAND} -E echo "MSI installer created: lemonade.msi" + DEPENDS ${EXECUTABLE_NAME} + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + COMMENT "Building WiX MSI installer (with Electron app)" + ) + + if(TARGET electron-app) + add_dependencies(wix_installer_full electron-app) + endif() + + list(APPEND WIX_INSTALLER_TARGETS wix_installer_full) + else() + message(STATUS "Python 3 interpreter not found. Skipping the Electron-enabled MSI target 'wix_installer_full'.") + endif() + + add_custom_target(wix_installers + DEPENDS ${WIX_INSTALLER_TARGETS} ) - message(STATUS "WiX installer target 'wix_installer' configured. Run 'cmake --build . --target wix_installer' to build MSI.") + message(STATUS "WiX installer targets configured. Run 'cmake --build . --target wix_installer_minimal' or 'wix_installer_full'.") else() - message(STATUS "WiX Toolset not found. MSI installer will not be available.") + message(STATUS "WiX Toolset not found. MSI installers will not be available.") message(STATUS " Install WiX Toolset 5.0.2 from: https://github.com/wixtoolset/wix/releases/download/v5.0.2/wix-cli-x64.msi") message(STATUS " Or visit: https://wixtoolset.org/") endif() @@ -320,14 +392,54 @@ add_custom_command(TARGET ${EXECUTABLE_NAME} POST_BUILD COMMENT "Copying resources to output directory" ) - # ============================================================ -# Applications: -# - lemonade-server -# - lemonade-tray (Windows only) -# - lemonade-log-viewer (Windows only) +# Electron App Integration (Cross-Platform) # ============================================================ +# Find Node.js and npm for building the Electron app +find_program(NODE_EXECUTABLE node) +find_program(NPM_EXECUTABLE npm) + +# Custom target to build the Electron app +if(NODE_EXECUTABLE AND NPM_EXECUTABLE) + if(WIN32) + set(ELECTRON_BUILD_TARGET "build:win") + elseif(APPLE) + set(ELECTRON_BUILD_TARGET "build:mac") + else() + set(ELECTRON_BUILD_TARGET "build:linux") + endif() + + add_custom_target(electron-app + COMMAND ${CMAKE_COMMAND} -E echo "Building Electron app for current platform..." + COMMAND ${CMAKE_COMMAND} -E chdir "${ELECTRON_APP_SOURCE_DIR}" ${NPM_EXECUTABLE} install + COMMAND ${CMAKE_COMMAND} -E chdir "${ELECTRON_APP_SOURCE_DIR}" ${NPM_EXECUTABLE} run ${ELECTRON_BUILD_TARGET} + COMMENT "Building Electron app with npm" + WORKING_DIRECTORY "${ELECTRON_APP_SOURCE_DIR}" + VERBATIM + ) + + message(STATUS "Node.js found: ${NODE_EXECUTABLE}") + message(STATUS "npm found: ${NPM_EXECUTABLE}") + message(STATUS "Electron app can be built with: cmake --build . --target electron-app") +else() + message(STATUS "Node.js or npm not found - Electron app build target disabled") + message(STATUS "Install Node.js to enable: https://nodejs.org/") +endif() + +# Custom command to copy Electron app files after building the C++ server +add_custom_command(TARGET ${EXECUTABLE_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E echo "Checking for Electron app at ${ELECTRON_APP_UNPACKED_DIR}..." + COMMAND ${CMAKE_COMMAND} + -DELECTRON_APP_UNPACKED_DIR=${ELECTRON_APP_UNPACKED_DIR} + -DTARGET_DIR=$ + -DELECTRON_EXE_NAME=${ELECTRON_EXE_NAME} + -P ${CMAKE_CURRENT_SOURCE_DIR}/CopyElectronApp.cmake + COMMENT "Copying Electron app if available" + VERBATIM +) + +# Add tray application subdirectory add_subdirectory(tray) @@ -336,7 +448,6 @@ add_subdirectory(tray) # ============================================================ if(UNIX AND NOT APPLE) set(CPACK_GENERATOR "DEB") - set(CPACK_PACKAGE_NAME "lemonade-server-minimal") set(CPACK_PACKAGE_VERSION "${PROJECT_VERSION}") set(CPACK_PACKAGE_VENDOR "Lemonade") set(CPACK_PACKAGE_CONTACT "lemonade@amd.com") @@ -347,9 +458,7 @@ if(UNIX AND NOT APPLE) set(CPACK_DEBIAN_PACKAGE_MAINTAINER "Lemonade Team ") set(CPACK_DEBIAN_PACKAGE_SECTION "utils") set(CPACK_DEBIAN_PACKAGE_PRIORITY "optional") - set(CPACK_DEBIAN_PACKAGE_DEPENDS "libcurl4, libssl3, libz1, unzip") set(CPACK_DEBIAN_PACKAGE_ARCHITECTURE "amd64") - set(CPACK_DEBIAN_FILE_NAME "${CPACK_PACKAGE_NAME}_${CPACK_PACKAGE_VERSION}_${CPACK_DEBIAN_PACKAGE_ARCHITECTURE}.deb") # Installation paths - use ~/.local for user install (no sudo needed) # Set via environment: cmake -DCPACK_PACKAGING_INSTALL_PREFIX=$HOME/.local .. @@ -368,8 +477,45 @@ if(UNIX AND NOT APPLE) DESTINATION share/lemonade-server/resources ) - # Post-install script to make directories writable - set(CPACK_DEBIAN_PACKAGE_CONTROL_EXTRA "${CMAKE_CURRENT_SOURCE_DIR}/postinst") + # Check if Electron app is available for full package + option(INCLUDE_ELECTRON_APP "Include Electron app in deb package" OFF) + + if(INCLUDE_ELECTRON_APP AND EXISTS "${ELECTRON_APP_UNPACKED_DIR}/${ELECTRON_EXE_NAME}") + message(STATUS "Electron app found at ${ELECTRON_APP_UNPACKED_DIR}") + message(STATUS "Building full deb package with Electron app") + + # Full package name + set(CPACK_PACKAGE_NAME "lemonade") + set(CPACK_DEBIAN_PACKAGE_DEPENDS "libcurl4, libssl3, libz1, unzip, libgtk-3-0, libnotify4, libnss3, libxss1, libxtst6, xdg-utils, libatspi2.0-0, libsecret-1-0") + set(CPACK_DEBIAN_FILE_NAME "${CPACK_PACKAGE_NAME}_${CPACK_PACKAGE_VERSION}_${CPACK_DEBIAN_PACKAGE_ARCHITECTURE}.deb") + + # Install Electron app + install(DIRECTORY "${ELECTRON_APP_UNPACKED_DIR}/" + DESTINATION share/lemonade-server/app + USE_SOURCE_PERMISSIONS + PATTERN "*.so" PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE GROUP_READ GROUP_EXECUTE WORLD_READ WORLD_EXECUTE + ) + + # Post-install script for full package + # Note: CPack requires the script to be named 'postinst' exactly + # We copy postinst-full to a temporary postinst file for CPack + configure_file("${CMAKE_CURRENT_SOURCE_DIR}/postinst-full" + "${CMAKE_CURRENT_BINARY_DIR}/postinst" COPYONLY) + set(CPACK_DEBIAN_PACKAGE_CONTROL_EXTRA "${CMAKE_CURRENT_BINARY_DIR}/postinst") + else() + # Minimal package (server only) + set(CPACK_PACKAGE_NAME "lemonade-server-minimal") + set(CPACK_DEBIAN_PACKAGE_DEPENDS "libcurl4, libssl3, libz1, unzip") + set(CPACK_DEBIAN_FILE_NAME "${CPACK_PACKAGE_NAME}_${CPACK_PACKAGE_VERSION}_${CPACK_DEBIAN_PACKAGE_ARCHITECTURE}.deb") + + # Post-install script for minimal package + set(CPACK_DEBIAN_PACKAGE_CONTROL_EXTRA "${CMAKE_CURRENT_SOURCE_DIR}/postinst") + + if(INCLUDE_ELECTRON_APP) + message(WARNING "INCLUDE_ELECTRON_APP is ON but Electron app not found at ${ELECTRON_APP_UNPACKED_DIR}") + message(WARNING "Building minimal deb package instead") + endif() + endif() include(CPack) endif() diff --git a/src/cpp/CopyElectronApp.cmake b/src/cpp/CopyElectronApp.cmake new file mode 100644 index 0000000000..efc755989f --- /dev/null +++ b/src/cpp/CopyElectronApp.cmake @@ -0,0 +1,95 @@ +# Cross-platform script to copy Electron app files +# This script is executed via cmake -P by the post-build step + +# Check if the Electron app directory exists +if(EXISTS "${ELECTRON_APP_UNPACKED_DIR}") + message(STATUS "Found Electron app! Copying files to ${TARGET_DIR}...") + + # Copy the main Electron executable + file(GLOB ELECTRON_MAIN_EXE "${ELECTRON_APP_UNPACKED_DIR}/${ELECTRON_EXE_NAME}") + if(ELECTRON_MAIN_EXE) + file(COPY ${ELECTRON_MAIN_EXE} DESTINATION "${TARGET_DIR}") + message(STATUS " ✓ Copied ${ELECTRON_EXE_NAME}") + endif() + + # Copy DLL files (Windows) + file(GLOB DLL_FILES "${ELECTRON_APP_UNPACKED_DIR}/*.dll") + if(DLL_FILES) + file(COPY ${DLL_FILES} DESTINATION "${TARGET_DIR}") + message(STATUS " ✓ Copied DLL files") + endif() + + # Copy .so files (Linux) + file(GLOB SO_FILES "${ELECTRON_APP_UNPACKED_DIR}/*.so*") + if(SO_FILES) + file(COPY ${SO_FILES} DESTINATION "${TARGET_DIR}") + message(STATUS " ✓ Copied shared library files") + endif() + + # Copy .dylib files (macOS) + file(GLOB DYLIB_FILES "${ELECTRON_APP_UNPACKED_DIR}/*.dylib") + if(DYLIB_FILES) + file(COPY ${DYLIB_FILES} DESTINATION "${TARGET_DIR}") + message(STATUS " ✓ Copied dynamic library files") + endif() + + # Copy resource files (.pak, .bin, .dat, .json) + file(GLOB RESOURCE_FILES + "${ELECTRON_APP_UNPACKED_DIR}/*.pak" + "${ELECTRON_APP_UNPACKED_DIR}/*.bin" + "${ELECTRON_APP_UNPACKED_DIR}/*.dat" + "${ELECTRON_APP_UNPACKED_DIR}/*.json" + ) + if(RESOURCE_FILES) + file(COPY ${RESOURCE_FILES} DESTINATION "${TARGET_DIR}") + message(STATUS " ✓ Copied resource files") + endif() + + # Copy locales directory + if(EXISTS "${ELECTRON_APP_UNPACKED_DIR}/locales") + file(COPY "${ELECTRON_APP_UNPACKED_DIR}/locales" + DESTINATION "${TARGET_DIR}") + message(STATUS " ✓ Copied locales directory") + endif() + + # Copy Electron resources directory and merge with existing resources + if(EXISTS "${ELECTRON_APP_UNPACKED_DIR}/resources") + # Copy app.asar to resources directory (required by Electron) + if(EXISTS "${ELECTRON_APP_UNPACKED_DIR}/resources/app.asar") + file(COPY "${ELECTRON_APP_UNPACKED_DIR}/resources/app.asar" + DESTINATION "${TARGET_DIR}/resources") + message(STATUS " ✓ Copied app.asar to resources") + endif() + + # Copy dist directory (backend server and renderer) + if(EXISTS "${ELECTRON_APP_UNPACKED_DIR}/resources/dist") + file(COPY "${ELECTRON_APP_UNPACKED_DIR}/resources/dist" + DESTINATION "${TARGET_DIR}/resources") + message(STATUS " ✓ Copied dist directory to resources") + endif() + + # Copy elevate.exe if it exists (Windows privilege elevation utility) + if(EXISTS "${ELECTRON_APP_UNPACKED_DIR}/resources/elevate.exe") + file(COPY "${ELECTRON_APP_UNPACKED_DIR}/resources/elevate.exe" + DESTINATION "${TARGET_DIR}/resources") + message(STATUS " ✓ Copied elevate.exe to resources") + endif() + + message(STATUS " ✓ Merged Electron resources with server resources") + endif() + + # Copy frameworks directory (macOS) + if(EXISTS "${ELECTRON_APP_UNPACKED_DIR}/Frameworks") + file(COPY "${ELECTRON_APP_UNPACKED_DIR}/Frameworks" + DESTINATION "${TARGET_DIR}") + message(STATUS " ✓ Copied Frameworks directory") + endif() + + message(STATUS "Electron app copied successfully!") +else() + message(STATUS "Electron app not found (this is optional).") + message(STATUS "To build the Electron app, run:") + message(STATUS " cmake --build . --target electron-app") + message(STATUS "Or manually: cd src/app && npm run build") +endif() + diff --git a/src/cpp/build_installer.ps1 b/src/cpp/build_installer.ps1 index bd0723145c..22dcf81446 100644 --- a/src/cpp/build_installer.ps1 +++ b/src/cpp/build_installer.ps1 @@ -87,9 +87,9 @@ if ($LASTEXITCODE -ne 0) { Write-Host " Build complete" -ForegroundColor Green Write-Host "" -# Step 3: Build the MSI installer -Write-Host "Building WiX MSI installer..." -ForegroundColor Yellow -cmake --build $BuildDir --config $Configuration --target wix_installer +# Step 3: Build both MSI installers +Write-Host "Building WiX MSI installers..." -ForegroundColor Yellow +cmake --build $BuildDir --config $Configuration --target wix_installers if ($LASTEXITCODE -ne 0) { Write-Host "ERROR: MSI build failed!" -ForegroundColor Red exit $LASTEXITCODE @@ -97,26 +97,34 @@ if ($LASTEXITCODE -ne 0) { Write-Host "" # Success! -$MsiPath = Join-Path $ScriptDir "lemonade-server-minimal.msi" -if (Test-Path $MsiPath) { - $MsiSize = (Get-Item $MsiPath).Length / 1MB - Write-Host "================================================" -ForegroundColor Green - Write-Host " SUCCESS!" -ForegroundColor Green - Write-Host "================================================" -ForegroundColor Green - Write-Host "" - Write-Host "MSI installer created:" -ForegroundColor Cyan - Write-Host " Path: $MsiPath" -ForegroundColor White - Write-Host " Size: $([math]::Round($MsiSize, 2)) MB" -ForegroundColor White - Write-Host "" - Write-Host "To install:" -ForegroundColor Yellow - Write-Host " msiexec /i lemonade-server-minimal.msi" -ForegroundColor White - Write-Host "" - Write-Host "To install silently:" -ForegroundColor Yellow - Write-Host " msiexec /i lemonade-server-minimal.msi /qn" -ForegroundColor White +$MinimalMsi = Join-Path $ScriptDir "lemonade-server-minimal.msi" +$FullMsi = Join-Path $ScriptDir "lemonade.msi" + +Write-Host "================================================" -ForegroundColor Green +Write-Host " SUCCESS!" -ForegroundColor Green +Write-Host "================================================" -ForegroundColor Green +Write-Host "" + +if (Test-Path $MinimalMsi) { + $size = (Get-Item $MinimalMsi).Length / 1MB + Write-Host "Minimal server installer:" -ForegroundColor Cyan + Write-Host " Path: $MinimalMsi" -ForegroundColor White + Write-Host " Size: $([math]::Round($size, 2)) MB" -ForegroundColor White + Write-Host " Install: msiexec /i lemonade-server-minimal.msi" -ForegroundColor Yellow + Write-Host " Silent : msiexec /i lemonade-server-minimal.msi /qn" -ForegroundColor Yellow Write-Host "" } else { - Write-Host "WARNING: MSI file not found at expected location!" -ForegroundColor Yellow - Write-Host " Expected: $MsiPath" -ForegroundColor Yellow + Write-Host "WARNING: lemonade-server-minimal.msi was not found!" -ForegroundColor Yellow } - +if (Test-Path $FullMsi) { + $size = (Get-Item $FullMsi).Length / 1MB + Write-Host "Full installer (with Electron app):" -ForegroundColor Cyan + Write-Host " Path: $FullMsi" -ForegroundColor White + Write-Host " Size: $([math]::Round($size, 2)) MB" -ForegroundColor White + Write-Host " Install: msiexec /i lemonade.msi" -ForegroundColor Yellow + Write-Host " Silent : msiexec /i lemonade.msi /qn" -ForegroundColor Yellow + Write-Host "" +} else { + Write-Host "WARNING: lemonade.msi was not found (Python 3 + Electron build required)." -ForegroundColor Yellow +} diff --git a/src/cpp/include/lemon/utils/http_client.h b/src/cpp/include/lemon/utils/http_client.h index 388d23ac7d..ef3c466d1e 100644 --- a/src/cpp/include/lemon/utils/http_client.h +++ b/src/cpp/include/lemon/utils/http_client.h @@ -109,9 +109,9 @@ inline ProgressCallback create_throttled_progress_callback(size_t resume_offset int percent = is_complete ? 100 : static_cast((adjusted_current * 100) / adjusted_total); double mb_current = adjusted_current / (1024.0 * 1024.0); double mb_total = adjusted_total / (1024.0 * 1024.0); - std::cout << "\r Progress: " << percent << "% (" + std::cout << " Progress: " << percent << "% (" << std::fixed << std::setprecision(1) - << mb_current << "/" << mb_total << " MB)" << std::flush; + << mb_current << "/" << mb_total << " MB)" << std::endl; *last_print_time = now; if (is_complete) { diff --git a/src/cpp/include/lemon_tray/tray_app.h b/src/cpp/include/lemon_tray/tray_app.h index 363a423f80..4b9620e345 100644 --- a/src/cpp/include/lemon_tray/tray_app.h +++ b/src/cpp/include/lemon_tray/tray_app.h @@ -109,13 +109,13 @@ class TrayApp { void on_change_context_size(int new_ctx_size); void on_show_logs(); void on_open_documentation(); - void on_open_llm_chat(); - void on_open_model_manager(); void on_upgrade(); void on_quit(); // Helpers void open_url(const std::string& url); + void launch_electron_app(); + bool find_electron_app(); void show_notification(const std::string& title, const std::string& message); std::string get_loaded_model(); std::vector get_all_loaded_models(); @@ -125,6 +125,7 @@ class TrayApp { AppConfig config_; std::unique_ptr tray_; std::unique_ptr server_manager_; + std::string electron_app_path_; // State std::string loaded_model_; @@ -147,6 +148,14 @@ class TrayApp { pid_t log_viewer_pid_ = 0; #endif + // Electron app process tracking (for child process management and single-instance enforcement) +#ifdef _WIN32 + HANDLE electron_app_process_ = nullptr; + HANDLE electron_job_object_ = nullptr; // Job object to ensure child closes with parent +#else + pid_t electron_app_pid_ = 0; // Process ID of the Electron app (macOS/Linux) +#endif + // Log tail thread for console output (when show_console is true) std::atomic stop_tail_thread_{false}; std::thread log_tail_thread_; diff --git a/src/cpp/installer/Product.wxs.in b/src/cpp/installer/Product.wxs.in index b5d0eaab8b..54d004cfd2 100644 --- a/src/cpp/installer/Product.wxs.in +++ b/src/cpp/installer/Product.wxs.in @@ -1,4 +1,7 @@ + + + @@ -92,6 +95,9 @@ + + + @@ -143,12 +149,12 @@ - - + + - - + + @@ -215,6 +221,9 @@ + + + diff --git a/src/cpp/installer/generate_electron_fragment.py b/src/cpp/installer/generate_electron_fragment.py new file mode 100644 index 0000000000..1bc392125d --- /dev/null +++ b/src/cpp/installer/generate_electron_fragment.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +""" +Generate a WiX fragment that packages every file from the Electron app directory. + +This script walks the unpacked Electron app (e.g. electron-builder's win-unpacked) +and produces a WiX source fragment with: + +1. Directory structure rooted at a caller-provided Directory Id (e.g. ElectronAppDir) +2. A ComponentGroup that installs every file under that directory. + +Each component receives a deterministic GUID based on the relative file path to +ensure stability across builds. +""" + +from __future__ import annotations + +import argparse +import hashlib +from pathlib import Path +import sys +import textwrap +import uuid + + +MAX_WIX_ID_LENGTH = 70 + + +def make_safe_id(prefix: str, rel_path: str) -> str: + """Create a WiX-safe identifier with a deterministic hash suffix.""" + sanitized = [] + for ch in rel_path: + if ch.isalnum(): + sanitized.append(ch) + else: + sanitized.append("_") + safe = "".join(sanitized).strip("_") + if not safe: + safe = "root" + if not safe[0].isalpha(): + safe = f"_{safe}" + hash_suffix = hashlib.sha1(rel_path.encode("utf-8")).hexdigest()[:8] + max_body = MAX_WIX_ID_LENGTH - len(prefix) - len(hash_suffix) - 2 # underscores + if max_body < 1: + raise ValueError("Prefix is too long to form a valid WiX identifier.") + if len(safe) > max_body: + safe = safe[:max_body] + return f"{prefix}_{safe}_{hash_suffix}" + + +class DirNode: + def __init__( + self, rel_path: Path, dir_id: str, name: str | None, parent: "DirNode | None" + ): + self.rel_path = rel_path + self.id = dir_id + self.name = name + self.parent = parent + self.children: dict[str, DirNode] = {} + + +def ensure_directory_nodes( + root: DirNode, rel_path: Path, nodes_by_rel: dict[str, DirNode] +) -> DirNode: + """Ensure DirNode objects exist for every component of rel_path.""" + rel_str = rel_path.as_posix() + if rel_str in nodes_by_rel: + return nodes_by_rel[rel_str] + + parent = ( + ensure_directory_nodes(root, rel_path.parent, nodes_by_rel) + if rel_path.parts + else root + ) + dir_id = make_safe_id("ElectronDir", rel_str or "root") + node = DirNode(rel_path, dir_id, rel_path.name or None, parent) + parent.children[rel_path.name] = node + nodes_by_rel[rel_str or "."] = node + return node + + +def render_directory_xml(node: DirNode, indent: str = " ") -> list[str]: + """Recursively build XML lines for Directory hierarchy (excluding root).""" + lines: list[str] = [] + for child_name in sorted(node.children): + child = node.children[child_name] + lines.append(f'{indent}') + lines.extend(render_directory_xml(child, indent + " ")) + lines.append(f"{indent}") + return lines + + +def generate_wxs( + source_dir: Path, + output_path: Path, + component_group: str, + root_id: str, + path_variable: str, +) -> None: + if not source_dir.exists(): + raise FileNotFoundError( + f"Electron app directory not found: {source_dir}\n" + "Run the electron-app target (npm run build:win) before building the installer." + ) + + files = sorted(p for p in source_dir.rglob("*") if p.is_file()) + if not files: + raise FileNotFoundError( + f"No files found under {source_dir}. Did the Electron build complete successfully?" + ) + + root_node = DirNode(Path("."), root_id, None, None) + nodes_by_rel: dict[str, DirNode] = {".": root_node} + + for file_path in files: + rel_dir = file_path.relative_to(source_dir).parent + ensure_directory_nodes(root_node, rel_dir, nodes_by_rel) + + directory_lines = render_directory_xml(root_node) + + file_entries = [] + for file_path in files: + rel_path = file_path.relative_to(source_dir).as_posix() + rel_dir = file_path.relative_to(source_dir).parent.as_posix() or "." + dir_node = nodes_by_rel[rel_dir] + component_id = make_safe_id("ElectronComponent", rel_path) + file_id = make_safe_id("ElectronFile", rel_path) + guid_value = str( + uuid.uuid5(uuid.NAMESPACE_URL, f"lemonade/electron/{rel_path}") + ).upper() + guid = f"{{{guid_value}}}" + windows_rel_path = rel_path.replace("/", "\\") + file_entries.append( + textwrap.dedent( + f"""\ + + + """ + ).rstrip() + ) + + content = [ + '', + '', + " ", + f' ', + ] + if directory_lines: + content.extend(directory_lines) + content.append(" ") + content.append(" ") + content.append("") + content.append(" ") + content.append(f' ') + for entry in file_entries: + for line in entry.splitlines(): + content.append(f" {line}") + content.append(" ") + content.append(" ") + content.append("") + content.append("") + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text("\n".join(content), encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Generate WiX fragment for Electron app files." + ) + parser.add_argument( + "--source", + required=True, + type=Path, + help="Path to Electron win-unpacked directory.", + ) + parser.add_argument( + "--output", required=True, type=Path, help="Destination .wxs fragment path." + ) + parser.add_argument( + "--component-group", required=True, help="ComponentGroup Id to emit." + ) + parser.add_argument( + "--root-id", + required=True, + help="Directory Id where the Electron app root will be installed.", + ) + parser.add_argument( + "--path-variable", + default="ElectronSourceDir", + help="WiX preprocessor variable resolving to the Electron source directory.", + ) + args = parser.parse_args() + + try: + generate_wxs( + args.source.resolve(), + args.output.resolve(), + args.component_group, + args.root_id, + args.path_variable, + ) + except Exception as exc: # pylint: disable=broad-except + print(f"[generate_electron_fragment] ERROR: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/cpp/postinst-full b/src/cpp/postinst-full new file mode 100644 index 0000000000..94b305d83e --- /dev/null +++ b/src/cpp/postinst-full @@ -0,0 +1,36 @@ +#!/bin/bash + +# Post-install script for the full Lemonade package (with Electron app) + +INSTALL_DIR="/usr/local/share/lemonade-server" +LLAMA_DIR="${INSTALL_DIR}/llama" +APP_DIR="${INSTALL_DIR}/app" + +# Create llama directory if it doesn't exist and make it writable +mkdir -p "$LLAMA_DIR" +chmod 777 "$LLAMA_DIR" + +# Make the Electron app executable +if [ -f "$APP_DIR/lemonade" ]; then + chmod +x "$APP_DIR/lemonade" +fi + +# Fix chrome-sandbox permissions (required for Electron/Chromium sandbox) +# The chrome-sandbox binary must be owned by root and have SUID bit set +if [ -f "$APP_DIR/chrome-sandbox" ]; then + chown root:root "$APP_DIR/chrome-sandbox" + chmod 4755 "$APP_DIR/chrome-sandbox" +fi + +# Make other Electron helper binaries executable +for binary in "$APP_DIR/chrome_crashpad_handler" "$APP_DIR/libvulkan.so.1" "$APP_DIR/libvk_swiftshader.so" "$APP_DIR/libGLESv2.so" "$APP_DIR/libEGL.so"; do + if [ -f "$binary" ]; then + chmod +x "$binary" + fi +done + +# Ensure all .so files in the app directory are readable and executable +find "$APP_DIR" -name "*.so*" -type f -exec chmod 755 {} \; 2>/dev/null || true + +exit 0 + diff --git a/src/cpp/server/backends/llamacpp_server.cpp b/src/cpp/server/backends/llamacpp_server.cpp index d054c7a990..ae42609da1 100644 --- a/src/cpp/server/backends/llamacpp_server.cpp +++ b/src/cpp/server/backends/llamacpp_server.cpp @@ -421,7 +421,7 @@ void LlamaCppServer::install(const std::string& backend) { throw std::runtime_error("Failed to download llama-server: " + result.error_message); } - std::cout << std::endl << "[LlamaCpp] Download complete!" << std::endl; + std::cout << "[LlamaCpp] Download complete!" << std::endl; // Verify the downloaded file exists and is valid if (!fs::exists(zip_path)) { diff --git a/src/cpp/server/backends/ryzenaiserver.cpp b/src/cpp/server/backends/ryzenaiserver.cpp index 9a8e668a14..3cda469b7a 100644 --- a/src/cpp/server/backends/ryzenaiserver.cpp +++ b/src/cpp/server/backends/ryzenaiserver.cpp @@ -161,7 +161,7 @@ void RyzenAIServer::download_and_install() { throw std::runtime_error("Failed to download ryzenai-server from release"); } - std::cout << std::endl << "[RyzenAI-Server] Download complete!" << std::endl; + std::cout << "[RyzenAI-Server] Download complete!" << std::endl; // Verify the downloaded file exists and is valid if (!fs::exists(zip_path)) { diff --git a/src/cpp/server/server.cpp b/src/cpp/server/server.cpp index 77f9010e7a..8f6382adc3 100644 --- a/src/cpp/server/server.cpp +++ b/src/cpp/server/server.cpp @@ -4,6 +4,7 @@ #include "lemon/utils/path_utils.h" #include "lemon/streaming_proxy.h" #include "lemon/system_info.h" +#include "lemon/version.h" #include #include #include @@ -220,20 +221,15 @@ void Server::setup_static_files() { // Determine static files directory (relative to executable) std::string static_dir = utils::get_resource_path("resources/static"); - // Root path redirects to web UI - http_server_->Get("/", [](const httplib::Request&, httplib::Response& res) { - res.set_redirect("/webapp.html"); - }); - - // Special handler for webapp.html to replace template variables - http_server_->Get("/webapp.html", [this, static_dir](const httplib::Request&, httplib::Response& res) { - std::string webapp_path = static_dir + "/webapp.html"; - std::ifstream file(webapp_path); + // Root path - serve index.html with template variable replacement + http_server_->Get("/", [this, static_dir](const httplib::Request&, httplib::Response& res) { + std::string index_path = static_dir + "/index.html"; + std::ifstream file(index_path); if (!file.is_open()) { - std::cerr << "[Server] Could not open webapp.html at: " << webapp_path << std::endl; + std::cerr << "[Server] Could not open index.html at: " << index_path << std::endl; res.status = 404; - res.set_content("{\"error\": \"webapp.html not found\"}", "application/json"); + res.set_content("{\"error\": \"index.html not found\"}", "application/json"); return; } @@ -283,7 +279,7 @@ void Server::setup_static_files() { // Replace {{SERVER_PORT}} while ((pos = html_template.find("{{SERVER_PORT}}")) != std::string::npos) { - html_template.replace(pos, 17, std::to_string(port_)); + html_template.replace(pos, 15, std::to_string(port_)); } // Replace {{SERVER_MODELS_JS}} @@ -318,7 +314,6 @@ void Server::setup_static_files() { }); // Mount static files directory for other files (CSS, JS, images) - // Use /static prefix to avoid conflicts with webapp.html if (!http_server_->set_mount_point("/static", static_dir)) { std::cerr << "[Server WARNING] Could not mount static files from: " << static_dir << std::endl; std::cerr << "[Server] Web UI assets will not be available" << std::endl; @@ -478,7 +473,10 @@ void Server::handle_health(const httplib::Request& req, httplib::Response& res) nlohmann::json response = {{"status", "ok"}}; - // Add model loaded information (most recent for backward compatibility) + // Add version information + response["version"] = LEMON_VERSION_STRING; + + // Add model loaded information like Python implementation std::string loaded_checkpoint = router_->get_loaded_checkpoint(); std::string loaded_model = router_->get_loaded_model(); diff --git a/src/cpp/tray/server_manager.cpp b/src/cpp/tray/server_manager.cpp index 2685732371..918c3ff6a7 100644 --- a/src/cpp/tray/server_manager.cpp +++ b/src/cpp/tray/server_manager.cpp @@ -121,6 +121,15 @@ bool ServerManager::start_server( try { DEBUG_LOG(this, "Making HTTP request..."); auto health = get_health(); + + server_started_ = true; + +#ifndef _WIN32 + // Write PID file on Linux for efficient server discovery + DEBUG_LOG(this, "About to write PID file (PID: " << server_pid_ << ", Port: " << port_ << ")"); + write_pid_file(); +#endif + DEBUG_LOG(this, "Server process is running!"); process_started = true; break; // Process is up, move to next step @@ -148,7 +157,9 @@ bool ServerManager::start_server( std::cout << "Lemonade Server v" << LEMON_VERSION_STRING << " started on port " << port_ << std::endl; // Display localhost for 0.0.0.0 since that's what users can actually visit in a browser std::string display_host = (host_ == "0.0.0.0") ? "localhost" : host_; - std::cout << "Chat and manage models: http://" << display_host << ":" << port_ << std::endl; + std::cout << "API endpoint: http://" << display_host << ":" << port_ << "/api/v1" << std::endl; + std::cout << "Connect your apps to the endpoint above." << std::endl; + std::cout << "Documentation: https://lemonade-server.ai/" << std::endl; } server_started_ = true; @@ -180,7 +191,9 @@ bool ServerManager::start_server( std::cout << "Lemonade Server v" << LEMON_VERSION_STRING << " started on port " << port_ << std::endl; // Display localhost for 0.0.0.0 since that's what users can actually visit in a browser std::string display_host = (host_ == "0.0.0.0") ? "localhost" : host_; - std::cout << "Chat and manage models: http://" << display_host << ":" << port_ << std::endl; + std::cout << "API endpoint: http://" << display_host << ":" << port_ << "/api/v1" << std::endl; + std::cout << "Connect your apps to the endpoint above." << std::endl; + std::cout << "Documentation: https://lemonade-server.ai/" << std::endl; } server_started_ = true; diff --git a/src/cpp/tray/tray_app.cpp b/src/cpp/tray/tray_app.cpp index fb8b445332..870cf6fa64 100644 --- a/src/cpp/tray/tray_app.cpp +++ b/src/cpp/tray/tray_app.cpp @@ -191,6 +191,12 @@ static bool is_process_alive_not_zombie(pid_t pid) { TrayApp::TrayApp(int argc, char* argv[]) : current_version_(LEMON_VERSION_STRING) , should_exit_(false) +#ifdef _WIN32 + , electron_app_process_(nullptr) + , electron_job_object_(nullptr) +#else + , electron_app_pid_(0) +#endif { // Load defaults from environment variables before parsing command-line arguments load_env_defaults(); @@ -1233,10 +1239,9 @@ int TrayApp::execute_run_command() { if (server_manager_->load_model(model_name)) { std::cout << "Model loaded successfully!" << std::endl; - // Open browser to chat interface - std::string url = "http://" + config_.host + ":" + std::to_string(config_.port) + "/?model=" + model_name + "#llm-chat"; - std::cout << "Opening browser: " << url << std::endl; - open_url(url); + // Launch the Electron app + std::cout << "Launching Lemonade app..." << std::endl; + launch_electron_app(); } else { std::cerr << "Failed to load model" << std::endl; return 1; @@ -1666,6 +1671,18 @@ void TrayApp::build_menu() { Menu TrayApp::create_menu() { Menu menu; + // Open app - at the very top (only if Electron app is available on full installer) + if (electron_app_path_.empty()) { + // Try to find the Electron app if we haven't already + const_cast(this)->find_electron_app(); + } + if (!electron_app_path_.empty()) { + menu.add_item(MenuItem::Action("Open app", [this]() { launch_electron_app(); })); + menu.add_separator(); + } + + // Get loaded model once and cache it to avoid redundant health checks + std::string loaded = is_loading_model_ ? "" : get_loaded_model(); // Get all loaded models to display at top and for checkmarks std::vector loaded_models = is_loading_model_ ? std::vector() : get_all_loaded_models(); @@ -1781,10 +1798,6 @@ Menu TrayApp::create_menu() { // Main menu items menu.add_item(MenuItem::Action("Documentation", [this]() { on_open_documentation(); })); - menu.add_item(MenuItem::Action("LLM Chat", [this]() { on_open_llm_chat(); })); - menu.add_item(MenuItem::Action("Model Manager", [this]() { on_open_model_manager(); })); - - // Logs menu item (simplified - always debug logs now) menu.add_item(MenuItem::Action("Show Logs", [this]() { on_show_logs(); })); menu.add_separator(); @@ -1993,14 +2006,6 @@ void TrayApp::on_open_documentation() { open_url("https://lemonade-server.ai/docs/"); } -void TrayApp::on_open_llm_chat() { - open_url("http://" + config_.host + ":" + std::to_string(config_.port) + "/#llm-chat"); -} - -void TrayApp::on_open_model_manager() { - open_url("http://" + config_.host + ":" + std::to_string(config_.port) + "/#model-management"); -} - void TrayApp::on_upgrade() { // TODO: Implement upgrade functionality std::cout << "Upgrade functionality not yet implemented" << std::endl; @@ -2043,6 +2048,44 @@ void TrayApp::shutdown() { } #endif + // Close Electron app if open +#ifdef _WIN32 + if (electron_app_process_) { + // The job object will automatically terminate the process when we close it + // But we can optionally terminate it gracefully first + CloseHandle(electron_app_process_); + electron_app_process_ = nullptr; + } + if (electron_job_object_) { + // Closing the job object will terminate all processes in it + CloseHandle(electron_job_object_); + electron_job_object_ = nullptr; + } +#else + // macOS/Linux: Terminate the Electron app if it's running + if (electron_app_pid_ > 0) { + if (is_process_alive_not_zombie(electron_app_pid_)) { + std::cout << "Terminating Electron app (PID: " << electron_app_pid_ << ")..." << std::endl; + kill(electron_app_pid_, SIGTERM); + + // Wait briefly for graceful shutdown + for (int i = 0; i < 10; i++) { + if (!is_process_alive_not_zombie(electron_app_pid_)) { + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + + // Force kill if still alive + if (is_process_alive_not_zombie(electron_app_pid_)) { + std::cout << "Force killing Electron app..." << std::endl; + kill(electron_app_pid_, SIGKILL); + } + } + electron_app_pid_ = 0; + } +#endif + // Stop the server if (server_manager_) { stop_server(); @@ -2066,6 +2109,219 @@ void TrayApp::open_url(const std::string& url) { #endif } +bool TrayApp::find_electron_app() { + // Get directory of this executable (lemonade-tray.exe) + fs::path exe_dir; + +#ifdef _WIN32 + wchar_t exe_path[MAX_PATH]; + GetModuleFileNameW(NULL, exe_path, MAX_PATH); + exe_dir = fs::path(exe_path).parent_path(); +#else + char exe_path[PATH_MAX]; + ssize_t len = readlink("/proc/self/exe", exe_path, sizeof(exe_path) - 1); + if (len != -1) { + exe_path[len] = '\0'; + exe_dir = fs::path(exe_path).parent_path(); + } else { + return false; + } +#endif + + // The Electron app has exactly two possible locations: + // 1. Production (WIX installer): ../app/ relative to bin/ directory + // 2. Development: same directory (copied by CopyElectronApp.cmake) + +#ifdef _WIN32 + constexpr const char* exe_name = "Lemonade.exe"; +#elif defined(__APPLE__) + constexpr const char* exe_name = "Lemonade.app"; +#else + constexpr const char* exe_name = "lemonade"; +#endif + + // Check production path first (most common case) + fs::path production_path = exe_dir / ".." / "app" / exe_name; + if (fs::exists(production_path)) { + electron_app_path_ = fs::canonical(production_path).string(); + std::cout << "Found Electron app at: " << electron_app_path_ << std::endl; + return true; + } + + // Check development path (same directory as tray executable) + fs::path dev_path = exe_dir / exe_name; + if (fs::exists(dev_path)) { + electron_app_path_ = fs::canonical(dev_path).string(); + std::cout << "Found Electron app at: " << electron_app_path_ << std::endl; + return true; + } + + std::cerr << "Warning: Could not find Electron app" << std::endl; + std::cerr << " Checked: " << production_path.string() << std::endl; + std::cerr << " Checked: " << dev_path.string() << std::endl; + return false; +} + +void TrayApp::launch_electron_app() { + // Try to find the app if we haven't already + if (electron_app_path_.empty()) { + if (!find_electron_app()) { + std::cerr << "Error: Cannot launch Electron app - not found" << std::endl; + return; + } + } + +#ifdef _WIN32 + // Single-instance enforcement: Only allow one Electron app to be open at a time + // Reuse child process tracking to determine if the app is already running + if (electron_app_process_ != nullptr) { + // Check if the process is still alive + DWORD exit_code = 0; + if (GetExitCodeProcess(electron_app_process_, &exit_code) && exit_code == STILL_ACTIVE) { + std::cout << "Electron app is already running" << std::endl; + show_notification("App Already Running", "The Lemonade app is already open"); + return; + } else { + // Process has exited, clean up the handle + CloseHandle(electron_app_process_); + electron_app_process_ = nullptr; + } + } +#endif + + // Launch the Electron app +#ifdef _WIN32 + // Windows: Create a job object to ensure the Electron app closes when tray closes + if (!electron_job_object_) { + electron_job_object_ = CreateJobObjectA(NULL, NULL); + if (electron_job_object_) { + // Configure job to terminate all processes when the last handle is closed + JOBOBJECT_EXTENDED_LIMIT_INFORMATION job_info = {}; + job_info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + + if (!SetInformationJobObject( + electron_job_object_, + JobObjectExtendedLimitInformation, + &job_info, + sizeof(job_info))) { + std::cerr << "Warning: Failed to configure job object: " << GetLastError() << std::endl; + CloseHandle(electron_job_object_); + electron_job_object_ = nullptr; + } else { + std::cout << "Created job object for Electron app process management" << std::endl; + } + } else { + std::cerr << "Warning: Failed to create job object: " << GetLastError() << std::endl; + } + } + + // Launch the .exe + STARTUPINFOA si = {}; + si.cb = sizeof(si); + PROCESS_INFORMATION pi = {}; + + // Create the process + if (CreateProcessA( + electron_app_path_.c_str(), // Application name + NULL, // Command line + NULL, // Process security attributes + NULL, // Thread security attributes + FALSE, // Don't inherit handles + CREATE_SUSPENDED, // Create suspended so we can add to job before it runs + NULL, // Environment + NULL, // Current directory + &si, // Startup info + &pi)) // Process info + { + // Add the process to the job object if we have one + if (electron_job_object_) { + if (AssignProcessToJobObject(electron_job_object_, pi.hProcess)) { + std::cout << "Added Electron app to job object (will close with tray)" << std::endl; + } else { + std::cerr << "Warning: Failed to add process to job object: " << GetLastError() << std::endl; + } + } + + // Resume the process now that it's in the job object + ResumeThread(pi.hThread); + + // Store the process handle (don't close it - we need it for cleanup) + electron_app_process_ = pi.hProcess; + CloseHandle(pi.hThread); // We don't need the thread handle + + std::cout << "Launched Electron app" << std::endl; + } else { + std::cerr << "Failed to launch Electron app: " << GetLastError() << std::endl; + } +#elif defined(__APPLE__) + // Single-instance enforcement: Check if the Electron app is already running + if (electron_app_pid_ > 0) { + // Check if the process is still alive + if (kill(electron_app_pid_, 0) == 0) { + std::cout << "Electron app is already running (PID: " << electron_app_pid_ << ")" << std::endl; + show_notification("App Already Running", "The Lemonade app is already open"); + return; + } else { + // Process has exited, reset the PID + electron_app_pid_ = 0; + } + } + + // macOS: Use 'open' command to launch the .app + // Note: 'open' doesn't give us the PID directly, so we'll need to find it + std::string cmd = "open \"" + electron_app_path_ + "\""; + int result = system(cmd.c_str()); + if (result == 0) { + std::cout << "Launched Electron app" << std::endl; + + // Try to find the PID of the Electron app we just launched + // Look for process named "Lemonade" (the app name, not the .app bundle name) + std::this_thread::sleep_for(std::chrono::milliseconds(500)); // Give it time to start + FILE* pipe = popen("pgrep -n Lemonade", "r"); // -n = newest matching process + if (pipe) { + char buffer[128]; + if (fgets(buffer, sizeof(buffer), pipe) != nullptr) { + electron_app_pid_ = atoi(buffer); + std::cout << "Tracking Electron app (PID: " << electron_app_pid_ << ")" << std::endl; + } + pclose(pipe); + } + } else { + std::cerr << "Failed to launch Electron app" << std::endl; + } +#else + // Single-instance enforcement: Check if the Electron app is already running + if (electron_app_pid_ > 0) { + // Check if the process is still alive (and not a zombie) + if (is_process_alive_not_zombie(electron_app_pid_)) { + std::cout << "Electron app is already running (PID: " << electron_app_pid_ << ")" << std::endl; + show_notification("App Already Running", "The Lemonade app is already open"); + return; + } else { + // Process has exited, reset the PID + electron_app_pid_ = 0; + } + } + + // Linux: Launch the binary directly using fork/exec for proper PID tracking + pid_t pid = fork(); + if (pid == 0) { + // Child process: execute the Electron app + execl(electron_app_path_.c_str(), electron_app_path_.c_str(), nullptr); + // If execl returns, it failed + std::cerr << "Failed to execute Electron app: " << strerror(errno) << std::endl; + _exit(1); + } else if (pid > 0) { + // Parent process: store the PID + electron_app_pid_ = pid; + std::cout << "Launched Electron app (PID: " << electron_app_pid_ << ")" << std::endl; + } else { + // Fork failed + std::cerr << "Failed to launch Electron app: " << strerror(errno) << std::endl; + } +#endif +} + void TrayApp::show_notification(const std::string& title, const std::string& message) { if (tray_) { tray_->show_notification(title, message); diff --git a/src/lemonade/tools/server/static/index.html b/src/lemonade/tools/server/static/index.html new file mode 100644 index 0000000000..79caea9b03 --- /dev/null +++ b/src/lemonade/tools/server/static/index.html @@ -0,0 +1,279 @@ + + + + + + Lemonade Server + + + +
+ +

Lemonade Server

+
+ + Running on port {{SERVER_PORT}} +
+ + +
+

Quick Test

+

Send a chat completion request to verify the server is working:

+
+ + # List available models +curl http://localhost:{{SERVER_PORT}}/api/v1/models +
+
+ + # Chat with a model (replace MODEL_NAME) +curl -X POST http://localhost:{{SERVER_PORT}}/api/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{"model": "MODEL_NAME", "messages": [{"role": "user", "content": "Hello!"}]}' +
+
+ + +
+

💻 Want a GUI?

+ + +
+ + +
+ + + + diff --git a/src/lemonade/tools/server/static/logs.html b/src/lemonade/tools/server/static/logs.html index a6d5b822e7..d766a0071c 100644 --- a/src/lemonade/tools/server/static/logs.html +++ b/src/lemonade/tools/server/static/logs.html @@ -2,7 +2,7 @@ - Lemonade Server Logs + Server Logs