diff --git a/docs/dev-getting-started.md b/docs/dev-getting-started.md index 626ba77cd..69c61e8f0 100644 --- a/docs/dev-getting-started.md +++ b/docs/dev-getting-started.md @@ -24,7 +24,7 @@ This guide covers everything you need to build, test, and contribute to Lemonade - [Usage](#usage) - [lemonade-router (Server Only)](#lemonade-router-server-only) - [lemonade-server.exe (Console CLI Client)](#lemonade-serverexe-console-cli-client) - - [lemonade-tray.exe (GUI Tray Launcher)](#lemonade-trayexe-gui-tray-launcher---windows-only) + - [lemonade-tray (GUI Tray Application)](#lemonade-tray-gui-tray-application---windows-and-linux) - [Logging and Console Output](#logging-and-console-output) - [Testing](#testing) - [Basic Functionality Tests](#basic-functionality-tests) @@ -40,6 +40,7 @@ Lemonade consists of these main executables: - **lemonade-router.exe** - Core HTTP server executable that handles requests and LLM backend orchestration - **lemonade-server.exe** - Console CLI client for terminal users that manages server lifecycle, executes commands via HTTP API - **lemonade-tray.exe** (Windows only) - GUI tray launcher for desktop users, automatically starts `lemonade-server.exe serve` +- **lemonade-tray** (Linux, when compiled with GTK3+AppIndicator3) - System tray launcher using AppIndicator3 - **lemonade-log-viewer.exe** (Windows only) - Log file viewer with live tail support and installer-friendly file sharing ## Building from Source @@ -100,7 +101,8 @@ cmake --build --preset vs18 - `build/Release/lemonade-log-viewer.exe` - Log file viewer - **Linux/macOS:** - `build/lemonade-router` - HTTP server - - `build/lemonade-server` - Console CLI client + - `build/lemonade-server` - Console CLI client (always headless on Linux) + - `build/lemonade-tray` - GUI tray application (Linux only, built when GTK3+AppIndicator3 are found) - **Resources:** Automatically copied to `build/Release/resources/` on Windows, `build/resources/` on Linux/macOS (web UI files, model registry, backend version configuration) ### Building the Electron Desktop App (Optional) @@ -170,9 +172,10 @@ chmod +x build/app-appimage/Lemonade-*.AppImage - Security features enabled: Control Flow Guard, ASLR, DEP **Linux:** -- Linux builds are headless-only (no tray application) by default -- This avoids LGPL dependencies (GTK3, libappindicator3, libnotify) -- Run server using: `lemonade-server serve` (headless mode is automatic) +- `lemonade-server` is always headless on Linux (GTK-free, daemon-friendly); use `lemonade-server serve` to start the server +- `lemonade-tray` is a separate binary for the system tray, auto-detected at build time: built if GTK3 and AppIndicator3 (ayatana or upstream) are found +- To require tray support (fail if deps missing): `-DENABLE_LINUX_TRAY=ON` +- Optional tray dependencies: `gtk3-devel` and one of `ayatana-appindicator-glib-devel`, `ayatana-appindicator3-devel`, or `libappindicator-gtk3-devel` - Fully functional for server operations and model management - Uses permissively licensed dependencies only (MIT, Apache 2.0, BSD, curl license) - Clean .deb package with only runtime files (no development headers) @@ -594,19 +597,20 @@ A console application for terminal users: - Manages server lifecycle (start/stop persistent or ephemeral servers) - Communicates with `lemonade-router` via HTTP endpoints - Starts `lemonade-router` with appropriate options -- Provides optional system tray interface via `serve` command +- Provides optional system tray interface via `serve` command (Windows/macOS; on Linux the tray is provided by the separate `lemonade-tray` binary) **Command Types:** - **serve:** Starts a persistent server (with optional tray interface) - **run:** Starts persistent server, loads model, opens browser - **Other commands:** Use existing server or start ephemeral server, execute command via API, auto-cleanup -#### lemonade-tray (GUI Launcher - Windows Only) +#### lemonade-tray (GUI Tray Application - Windows and Linux) -A minimal WIN32 GUI application for desktop users: -- Simple launcher that starts `lemonade-server.exe serve` +A GUI application for desktop users that exposes the server via a system tray icon: +- **Windows:** Minimal launcher — finds `lemonade-server.exe`, launches it with the `serve` command, then exits. The server process owns the tray icon. +- **Linux:** Tray application (requires GTK3 + AppIndicator3). Connects to an already-running server if one is found; otherwise starts one (via systemd if a unit is installed, or by spawning `lemonade-router` directly). - Zero console output or CLI interface -- Used by Start Menu, Desktop shortcuts, and autostart +- Used by application launchers, desktop shortcuts, and autostart entries - Provides seamless GUI experience for non-technical users ### Client-Server Communication @@ -704,7 +708,7 @@ The `lemonade-server` executable is the command-line interface for terminal user # Run a model (starts persistent server with tray and opens browser) ./lemonade-server run Llama-3.2-1B-Instruct-CPU -# Start persistent server (with tray on Windows/macOS, headless on Linux) +# Start persistent server (with tray on Windows/macOS; always headless on Linux — use lemonade-tray for tray) ./lemonade-server serve # Start persistent server without tray (headless mode, explicit on all platforms) @@ -726,24 +730,27 @@ The `lemonade-server` executable is the command-line interface for terminal user **Note:** `lemonade-router` is always launched with `--log-level debug` for optimal troubleshooting. Use `--log-level debug` on `lemonade-server` commands to see client-side debug output. -### lemonade-tray.exe (GUI Tray Launcher - Windows Only) +### lemonade-tray (GUI Tray Application - Windows and Linux) -The `lemonade-tray` executable is a simple GUI launcher for desktop users: -- Double-click from Start Menu or Desktop to start server -- Automatically runs `lemonade-server.exe serve` in tray mode -- Zero console windows or CLI interface +The `lemonade-tray` executable provides a system tray icon for desktop users: +- Double-click from Start Menu, application launcher, or Desktop to start server +- Zero console windows or CLI interface — always starts the tray directly - Perfect for non-technical users - Single-instance protection: shows friendly message if already running -**What it does:** -1. Finds `lemonade-server.exe` in the same directory -2. Launches it with the `serve` command -3. Exits immediately (server continues running with tray icon) +**Platform support:** +- **Windows:** Always available; uses Win32 notification area APIs. Acts as a minimal launcher: finds `lemonade-server.exe` in the same directory, launches it with the `serve` command, then exits (the server process owns the tray icon). +- **Linux:** Available when compiled with GTK3 + AppIndicator3 support (auto-detected at build time). Connects to an already-running server if one is found; otherwise starts one (via systemd if a unit is installed, or by spawning `lemonade-router` directly). + +**What it does (Linux):** +1. Starts immediately in tray mode (no subcommand needed) +2. Connects to an already-running server via the PID file, or starts one (via systemd if a unit is installed, otherwise spawns `lemonade-router` directly) +3. Shows a system tray icon connected to the server **When to use:** -- Launching from Start Menu +- Launching from Start Menu (Windows) or application launcher (Linux) - Desktop shortcuts -- Windows startup +- Windows startup / Linux autostart - Any GUI/point-and-click scenario **System Tray Features (when running):** diff --git a/docs/faq.md b/docs/faq.md index 4fa87428b..42eb66d59 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -240,7 +240,7 @@ ### 1. **What if I encounter installation or runtime errors?** - Check the Lemonade Server logs via the App (all supported OSes) or tray icon (Windows only). Common issues include model compatibility or outdated versions. + Check the Lemonade Server logs via the App or tray icon. Common issues include model compatibility or outdated versions. 👉 [Open an Issue on GitHub](https://github.com/lemonade-sdk/lemonade/issues) diff --git a/docs/server/server_integration.md b/docs/server/server_integration.md index 5076781d1..807ac2f78 100644 --- a/docs/server/server_integration.md +++ b/docs/server/server_integration.md @@ -101,7 +101,7 @@ By default, the server runs on port 8000. Optionally, you can specify a custom p lemonade-server serve --port 8123 ``` -You can also prevent the server from showing a system tray icon by using the `--no-tray` flag (Windows and macOS): +You can also prevent the server from showing a system tray icon by using the `--no-tray` flag: ```bash lemonade-server serve --no-tray diff --git a/setup.sh b/setup.sh index 9abe274fb..145792c8e 100755 --- a/setup.sh +++ b/setup.sh @@ -170,6 +170,123 @@ else fi fi +# Check optional Linux tray dependencies (AppIndicator3 + libnotify [+ GTK3 if not using glib variant]) +# These are optional - lemonade-tray is only built when they are present. +# lemonade-server always builds without them (headless, daemon-friendly). +if [ "$OS" = "linux" ] && command_exists pkg-config; then + print_info "Checking optional Linux tray dependencies (AppIndicator3)..." + missing_tray_packages=() + appindicator_glib_found=false + + # Check AppIndicator first — the glib variant is GTK-free and preferred. + if pkg-config --exists ayatana-appindicator-glib-0.1 2>/dev/null || \ + pkg-config --exists ayatana-appindicator-glib 2>/dev/null; then + print_success "AppIndicator3 glib variant is installed (tray support, GTK-free)" + appindicator_glib_found=true + elif pkg-config --exists ayatana-appindicator3-0.1 2>/dev/null || \ + pkg-config --exists appindicator3-0.1 2>/dev/null; then + print_success "AppIndicator3 is installed (tray support)" + else + print_warning "AppIndicator3 not found (optional, needed for lemonade-tray)" + if command_exists apt; then + missing_tray_packages+=("libayatana-appindicator3-dev") + elif command_exists pacman; then + missing_tray_packages+=("libayatana-appindicator") + elif command_exists dnf; then + missing_tray_packages+=("libayatana-appindicator-gtk3-devel") + fi + fi + + # dbusmenu-glib is required alongside the glib appindicator variant so that + # GNOME Shell can find com.canonical.dbusmenu (it does not speak org.gtk.Menus). + if [ "$appindicator_glib_found" = true ]; then + if pkg-config --exists dbusmenu-glib-0.4 2>/dev/null; then + print_success "dbusmenu-glib is installed (GNOME Shell tray menu support)" + else + print_warning "dbusmenu-glib not found (optional, needed for tray menus on GNOME Shell)" + if command_exists apt; then + missing_tray_packages+=("libdbusmenu-glib-dev") + elif command_exists pacman; then + missing_tray_packages+=("libdbusmenu-glib") + elif command_exists dnf; then + missing_tray_packages+=("dbusmenu-glib-devel") + fi + fi + fi + + # GTK3 is only required when NOT using the glib appindicator variant. + if [ "$appindicator_glib_found" = false ]; then + if pkg-config --exists gtk+-3.0 2>/dev/null; then + print_success "gtk3 is installed (tray support)" + else + print_warning "gtk3 not found (optional, needed for lemonade-tray)" + if command_exists apt; then + missing_tray_packages+=("libgtk-3-dev") + elif command_exists pacman; then + missing_tray_packages+=("gtk3") + elif command_exists dnf; then + missing_tray_packages+=("gtk3-devel") + fi + fi + fi + + if pkg-config --exists libnotify 2>/dev/null; then + print_success "libnotify is installed (tray notifications)" + else + print_warning "libnotify not found (optional, enables tray notifications)" + if command_exists apt; then + missing_tray_packages+=("libnotify-dev") + elif command_exists pacman; then + missing_tray_packages+=("libnotify") + elif command_exists dnf; then + missing_tray_packages+=("libnotify-devel") + fi + fi + + if [ ${#missing_tray_packages[@]} -gt 0 ]; then + echo "" + print_warning "Optional tray packages missing (lemonade-tray will not be built):" + for pkg in "${missing_tray_packages[@]}"; do + echo " - $pkg" + done + echo "" + + # Build install command for display + if command_exists apt; then + tray_install_cmd="sudo apt install -y ${missing_tray_packages[*]}" + elif command_exists pacman; then + tray_install_cmd="sudo pacman -S --needed --noconfirm ${missing_tray_packages[*]}" + elif command_exists dnf; then + tray_install_cmd="sudo dnf install -y ${missing_tray_packages[*]}" + fi + + print_info "To enable tray support, run: $tray_install_cmd" + + if [ -n "$CI" ] || [ -n "$GITHUB_ACTIONS" ]; then + print_info "CI environment detected, skipping optional tray dependencies." + else + read -p "Install optional tray dependencies now? (y/N): " -n 1 -r + echo "" + if [[ $REPLY =~ ^[Yy]$ ]]; then + print_info "Installing optional tray dependencies..." + if command_exists apt; then + maybe_sudo apt install -y "${missing_tray_packages[@]}" + elif command_exists pacman; then + maybe_sudo pacman -S --needed --noconfirm "${missing_tray_packages[@]}" + elif command_exists dnf; then + maybe_sudo dnf install -y "${missing_tray_packages[@]}" + fi + print_success "Optional tray dependencies installed" + else + print_info "Skipping optional tray dependencies (lemonade-tray will not be built)" + fi + fi + else + print_success "All optional tray dependencies are installed (lemonade-tray will be built)" + fi + echo "" +fi + # Check Node.js and npm print_info "Checking Node.js and npm installation..." diff --git a/src/cpp/BUILD_OPTIONS.md b/src/cpp/BUILD_OPTIONS.md index 508614fcf..ebed13361 100644 --- a/src/cpp/BUILD_OPTIONS.md +++ b/src/cpp/BUILD_OPTIONS.md @@ -1,5 +1,32 @@ # Lemonade Build Options +## Linux Tray Configuration + +### Build Options + +#### `ENABLE_LINUX_TRAY` (Default: **OFF** / auto-detect) +Enable system tray support on Linux via GTK3 + AppIndicator3. + +- When **OFF** (default): Tray support is auto-detected at configure time. If GTK3 and one of the AppIndicator3 libraries are found, `lemonade-tray` is built as a separate executable. `lemonade-server` on Linux is always headless regardless. If dependencies are missing, only `lemonade-server` is built (headless mode). +- When **ON**: Tray support is required — the build will fail if the dependencies are not found. + +Optional runtime dependencies (for tray support): +- `gtk3-devel` (or `libgtk-3-dev` on Debian/Ubuntu) +- One of: + - `ayatana-appindicator-glib-devel` (recommended successor, GLib-only) + - `ayatana-appindicator3-devel` (Ayatana GTK3 variant) + - `libappindicator-gtk3-devel` (upstream libappindicator3) + +```bash +# Auto-detect (default): tray enabled if deps are found +cmake ../src/cpp + +# Explicitly require tray support (fail if deps missing) +cmake -DENABLE_LINUX_TRAY=ON ../src/cpp +``` + +--- + ## React App Build Configuration The CMake build system allows you to control whether the React web app and/or Electron desktop app are built and included in the server. diff --git a/src/cpp/include/lemon/cli_parser.h b/src/cpp/include/lemon/cli_parser.h index 1f590c80c..445e57530 100644 --- a/src/cpp/include/lemon/cli_parser.h +++ b/src/cpp/include/lemon/cli_parser.h @@ -24,8 +24,8 @@ struct ServerConfig { struct TrayConfig { std::string command; // No default - must be explicitly specified - // Default to headless mode on Linux (no tray support), tray mode on other platforms -#if defined(__linux__) && !defined(__ANDROID__) + // Default to headless mode on Linux only when tray support is not available (no GTK/AppIndicator) +#if defined(__linux__) && !defined(__ANDROID__) && !defined(HAVE_APPINDICATOR) bool no_tray = true; #else bool no_tray = false; diff --git a/src/cpp/include/lemon_tray/platform/linux_tray.h b/src/cpp/include/lemon_tray/platform/linux_tray.h index 9e7456021..21c8c3079 100644 --- a/src/cpp/include/lemon_tray/platform/linux_tray.h +++ b/src/cpp/include/lemon_tray/platform/linux_tray.h @@ -4,6 +4,26 @@ #include "tray_interface.h" +#ifdef HAVE_APPINDICATOR +#ifdef HAVE_AYATANA_APPINDICATOR_GLIB +#include // glib variant (GTK-free, GIO only) +#ifdef HAVE_DBUSMENU_GLIB +#include +#include +#endif +#else +#include +#ifdef HAVE_AYATANA_APPINDICATOR +#include // GTK3 Ayatana variant +#else +#include // upstream fallback +#endif // HAVE_AYATANA_APPINDICATOR +#endif // HAVE_AYATANA_APPINDICATOR_GLIB +#ifdef HAVE_LIBNOTIFY +#include +#endif // HAVE_LIBNOTIFY +#endif // HAVE_APPINDICATOR + namespace lemon_tray { class LinuxTray : public TrayInterface { @@ -27,8 +47,26 @@ class LinuxTray : public TrayInterface { void set_ready_callback(std::function callback) override; private: - // Headless implementation - no GUI dependencies - // Linux tray support disabled to avoid LGPL dependencies +#ifdef HAVE_APPINDICATOR + AppIndicator* indicator_; +#ifdef HAVE_AYATANA_APPINDICATOR_GLIB + GMainLoop* main_loop_; + GMenu* g_menu_; + GSimpleActionGroup* action_group_; + std::vector*> callbacks_; + void build_g_menu(const Menu& menu, GMenu* parent, GSimpleActionGroup* actions, + int& action_id, std::vector*>& callbacks); +#ifdef HAVE_DBUSMENU_GLIB + DbusmenuServer* dbusmenu_server_; + void build_dbusmenu(const Menu& menu, DbusmenuMenuitem* parent, + std::vector*>& callbacks); +#endif +#else + GtkWidget* gtk_menu_; + void build_gtk_menu(const Menu& menu, GtkWidget* parent_menu); +#endif // HAVE_AYATANA_APPINDICATOR_GLIB +#endif // HAVE_APPINDICATOR + std::string app_name_; std::string icon_path_; std::function ready_callback_; diff --git a/src/cpp/include/lemon_tray/platform/tray_interface.h b/src/cpp/include/lemon_tray/platform/tray_interface.h index 819397928..9ea988f48 100644 --- a/src/cpp/include/lemon_tray/platform/tray_interface.h +++ b/src/cpp/include/lemon_tray/platform/tray_interface.h @@ -28,6 +28,7 @@ struct MenuItem { MenuCallback callback; bool enabled = true; bool checked = false; + bool is_checkable = false; bool is_separator = false; std::shared_ptr submenu = nullptr; int id = -1; // Platform-specific menu item ID @@ -53,6 +54,7 @@ struct MenuItem { item.text = text; item.callback = callback; item.checked = checked; + item.is_checkable = true; item.enabled = enabled; return item; } diff --git a/src/cpp/include/lemon_tray/tray_app.h b/src/cpp/include/lemon_tray/tray_app.h index b18ab4b62..8f86ec728 100644 --- a/src/cpp/include/lemon_tray/tray_app.h +++ b/src/cpp/include/lemon_tray/tray_app.h @@ -95,6 +95,11 @@ class TrayApp { void on_open_documentation(); void on_upgrade(); void on_quit(); +#ifndef _WIN32 + void on_start_server(); + void on_stop_server(); + void on_stop_external_server(); +#endif // Helpers void open_url(const std::string& url); @@ -123,6 +128,10 @@ class TrayApp { std::vector downloaded_models_; bool should_exit_; bool process_owns_server_ = false; +#ifndef _WIN32 + int external_server_pid_ = 0; // PID of external server we didn't spawn (for SIGTERM) +#endif + bool last_menu_server_reachable_ = false; // For refresh detection // Model loading state std::atomic is_loading_model_{false}; diff --git a/src/cpp/server/cli_parser.cpp b/src/cpp/server/cli_parser.cpp index 22ab797e5..e872daa80 100644 --- a/src/cpp/server/cli_parser.cpp +++ b/src/cpp/server/cli_parser.cpp @@ -86,7 +86,11 @@ CLIParser::CLIParser() app_.set_version_flag("-v,--version", (APP_NAME " version " LEMON_VERSION_STRING)); #ifdef LEMONADE_TRAY +#ifdef LEMONADE_TRAY_GUI + app_.require_subcommand(0, 1); +#else app_.require_subcommand(1); +#endif app_.set_help_all_flag("--help-all", "Print help for all commands"); // Serve @@ -214,7 +218,9 @@ CLIParser::CLIParser() ->default_val(config_.port); // Tray +#ifndef LEMONADE_NO_TRAY_SUBCOMMAND CLI::App* tray = app_.add_subcommand("tray", "Launch tray interface for running server"); +#endif #else add_serve_options(&app_, config_); #endif @@ -222,11 +228,12 @@ CLIParser::CLIParser() int CLIParser::parse(int argc, char** argv) { try { -#ifdef LEMONADE_TRAY - // Show help if no arguments provided - if (argc == 1) { - throw CLI::CallForHelp(); - } +#ifdef LEMONADE_TRAY_GUI + // lemonade-tray is always a tray launcher — no subcommand selection needed + tray_config_.command = "tray"; + should_continue_ = true; + exit_code_ = 0; + return 0; #endif app_.parse(argc, argv); diff --git a/src/cpp/server/recipe_options.cpp b/src/cpp/server/recipe_options.cpp index 83b9d0834..efec87181 100644 --- a/src/cpp/server/recipe_options.cpp +++ b/src/cpp/server/recipe_options.cpp @@ -172,7 +172,7 @@ std::vector RecipeOptions::to_cli_options(const json& raw_options) const std::string opt_name = opt["option_name"]; if (raw_options.contains(opt_name)) { auto val = raw_options[opt_name]; - if (val != "") { + if (!val.is_null() && val != "") { cli.push_back(key); if (val.is_number_float()) { cli.push_back(std::to_string((double) val)); @@ -200,9 +200,9 @@ RecipeOptions::RecipeOptions(const std::string& recipe, const json& options) { } static std::string format_option_for_logging(const json& opt) { + if (opt.is_null() || opt == "") return "(none)"; if (opt.is_number_float()) return std::to_string((double) opt); if (opt.is_number_integer()) return std::to_string((int) opt); - if (opt == "") return "(none)"; return opt; } diff --git a/src/cpp/tray/CMakeLists.txt b/src/cpp/tray/CMakeLists.txt index d9e425721..d64697f75 100644 --- a/src/cpp/tray/CMakeLists.txt +++ b/src/cpp/tray/CMakeLists.txt @@ -12,14 +12,14 @@ if(WIN32) endif() # Platform-specific sources -set(PLATFORM_SOURCES "") -set(PLATFORM_LIBS "") +set(TRAY_SOURCES "") +set(TRAY_LIBS "") if(WIN32) - set(PLATFORM_SOURCES + set(TRAY_SOURCES platform/windows_tray.cpp ) - set(PLATFORM_LIBS + set(TRAY_LIBS user32 shell32 ole32 @@ -31,7 +31,7 @@ if(WIN32) # For true GUI-only mode, could add WIN32 flag and use WinMain elseif(APPLE) - set(PLATFORM_SOURCES + set(TRAY_SOURCES platform/macos_tray.mm ) @@ -40,7 +40,7 @@ elseif(APPLE) find_library(USERNOTIFICATIONS_LIBRARY UserNotifications REQUIRED) find_library(METAL_LIBRARY Metal REQUIRED) - set(PLATFORM_LIBS + set(TRAY_LIBS ${COCOA_LIBRARY} ${FOUNDATION_LIBRARY} ${USERNOTIFICATIONS_LIBRARY} @@ -55,16 +55,89 @@ elseif(APPLE) ) elseif(UNIX) # Linux - # Linux builds are headless-only (no tray support) - # This avoids LGPL dependencies (GTK3, libappindicator3, libnotify) - message(STATUS "Linux build: Tray application disabled (headless mode only)") - message(STATUS "Use 'lemonade-server serve --no-tray' to run server") + # Check for Linux tray dependencies (GTK3, AppIndicator3) + find_package(PkgConfig REQUIRED) + + # Allow explicitly enabling/disabling tray support from command line. + # Default is OFF (auto-detect): tray is enabled if dependencies are found. + # Set -DENABLE_LINUX_TRAY=ON to require tray support (fail if deps missing). + option(ENABLE_LINUX_TRAY "Enable Linux system tray support (requires AppIndicator3; GTK3 not needed when using the glib variant)" OFF) + + pkg_check_modules(GTK3 gtk+-3.0) + + # Prefer the newer GLib variant (GTK-free, recommended successor). + # The module is named ayatana-appindicator-glib-0.1 upstream but some distros + # (e.g. Ubuntu resolute) ship it without the version suffix. + # Fall back to the Ayatana GTK3 variant, then upstream libappindicator3. + pkg_check_modules(APP_INDICATOR_GLIB ayatana-appindicator-glib-0.1) + if(NOT APP_INDICATOR_GLIB_FOUND) + pkg_check_modules(APP_INDICATOR_GLIB ayatana-appindicator-glib) + endif() + if(NOT APP_INDICATOR_GLIB_FOUND) + # Prefer Ayatana GTK3 variant, fall back to upstream libappindicator3. + pkg_check_modules(APP_INDICATOR3 ayatana-appindicator3-0.1) + if(APP_INDICATOR3_FOUND) + set(HAVE_AYATANA_APPINDICATOR TRUE) + else() + pkg_check_modules(APP_INDICATOR3 appindicator3-0.1) + endif() + endif() - # Include stub Linux tray implementation (headless mode) - set(PLATFORM_SOURCES - platform/linux_tray.cpp - ) - set(PLATFORM_LIBS "") + pkg_check_modules(LIBNOTIFY libnotify) + + if(ENABLE_LINUX_TRAY AND NOT (APP_INDICATOR_GLIB_FOUND OR (GTK3_FOUND AND APP_INDICATOR3_FOUND))) + message(FATAL_ERROR "ENABLE_LINUX_TRAY=ON but required dependencies not found. " + "Install one of: ayatana-appindicator-glib-devel (GTK-free), " + "or gtk3-devel + ayatana-appindicator3-devel / libappindicator-gtk3-devel.") + endif() + + if(APP_INDICATOR_GLIB_FOUND OR (GTK3_FOUND AND APP_INDICATOR3_FOUND)) + set(ENABLE_LINUX_TRAY ON) + + if(APP_INDICATOR_GLIB_FOUND) + # GTK-free path: only GIO/GLib required. + set(TRAY_LIBS ${APP_INDICATOR_GLIB_LIBRARIES}) + set(TRAY_INCLUDE_DIRS ${APP_INDICATOR_GLIB_INCLUDE_DIRS}) + set(TRAY_DEFINITIONS HAVE_APPINDICATOR HAVE_AYATANA_APPINDICATOR HAVE_AYATANA_APPINDICATOR_GLIB) + # ayatana-appindicator-glib exports menus via org.gtk.Menus (GMenuModel), + # but GNOME Shell's AppIndicator extension currently only speaks + # com.canonical.dbusmenu. We bridge the gap by also registering a + # DbusmenuServer at the same D-Bus path. Both interfaces coexist safely: + # once GNOME Shell adopts org.gtk.Menus it will use that natively and + # ignore the bridge. dbusmenu-glib is optional — if absent, the bridge + # is omitted and tray menus will not work on GNOME Shell. + pkg_check_modules(DBUSMENU_GLIB dbusmenu-glib-0.4) + if(DBUSMENU_GLIB_FOUND) + list(APPEND TRAY_LIBS ${DBUSMENU_GLIB_LIBRARIES}) + list(APPEND TRAY_INCLUDE_DIRS ${DBUSMENU_GLIB_INCLUDE_DIRS}) + list(APPEND TRAY_DEFINITIONS HAVE_DBUSMENU_GLIB) + else() + message(WARNING "dbusmenu-glib-0.4 not found — tray menus will not " + "work on GNOME Shell (install libdbusmenu-glib-dev or equivalent)") + endif() + else() + # GTK3 path. + set(TRAY_LIBS + ${GTK3_LIBRARIES} + ${APP_INDICATOR3_LIBRARIES} + ) + set(TRAY_INCLUDE_DIRS + ${GTK3_INCLUDE_DIRS} + ${APP_INDICATOR3_INCLUDE_DIRS} + ) + set(TRAY_DEFINITIONS HAVE_APPINDICATOR) + if(HAVE_AYATANA_APPINDICATOR) + list(APPEND TRAY_DEFINITIONS HAVE_AYATANA_APPINDICATOR) + endif() + endif() + + if(LIBNOTIFY_FOUND) + list(APPEND TRAY_LIBS ${LIBNOTIFY_LIBRARIES}) + list(APPEND TRAY_DEFINITIONS HAVE_LIBNOTIFY) + list(APPEND TRAY_INCLUDE_DIRS ${LIBNOTIFY_INCLUDE_DIRS}) + endif() + + endif() endif() # Common sources @@ -106,21 +179,33 @@ if(WIN32) endif() # ============================================================ -# lemonade-server.exe - Console CLI client +# lemonade-server - Console CLI / daemon # ============================================================ -add_executable(lemonade-server - ${COMMON_SOURCES} - ${PLATFORM_SOURCES} -) +add_executable(lemonade-server ${COMMON_SOURCES}) + +if(UNIX AND NOT APPLE) + # Linux: always headless, no GTK deps — daemon-friendly + target_sources(lemonade-server PRIVATE platform/linux_tray.cpp) + # No TRAY_LIBS, no TRAY_DEFINITIONS, no TRAY_INCLUDE_DIRS +else() + # Windows / macOS: use platform tray sources as before + target_sources(lemonade-server PRIVATE ${TRAY_SOURCES}) + target_link_libraries(lemonade-server PRIVATE ${TRAY_LIBS}) + target_include_directories(lemonade-server PRIVATE ${TRAY_INCLUDE_DIRS}) + target_compile_definitions(lemonade-server PRIVATE ${TRAY_DEFINITIONS}) +endif() target_link_libraries(lemonade-server PRIVATE nlohmann_json::nlohmann_json - ${PLATFORM_LIBS} +) + +target_include_directories(lemonade-server PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../include ) # Link httplib based on what's available (set by parent CMakeLists.txt) if(USE_SYSTEM_HTTPLIB) - target_link_libraries(lemonade-server PRIVATE cpp-httplib) + target_link_libraries(lemonade-server PRIVATE httplib::httplib) else() target_link_libraries(lemonade-server PRIVATE httplib::httplib) endif() @@ -132,16 +217,14 @@ else() target_link_libraries(lemonade-server PRIVATE CLI11::CLI11) endif() -# Add include directories for header-only system packages -target_include_directories(lemonade-server PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/../include -) - if(USE_SYSTEM_HTTPLIB AND HTTPLIB_INCLUDE_DIRS) target_include_directories(lemonade-server PRIVATE ${HTTPLIB_INCLUDE_DIRS}) endif() -target_compile_definitions(lemonade-server PRIVATE LEMONADE_TRAY) +target_compile_definitions(lemonade-server PRIVATE + LEMONADE_TRAY + $<$:LEMONADE_NO_TRAY_SUBCOMMAND> +) # Enable ARC (Automatic Reference Counting) for macOS Objective-C++ files if(APPLE) @@ -207,6 +290,73 @@ if(WIN32) ) endif() +# ============================================================ +# lemonade-tray - Full GUI tray application (Linux only) +# ============================================================ +if(UNIX AND NOT APPLE AND ENABLE_LINUX_TRAY) + add_executable(lemonade-tray + ${COMMON_SOURCES} + platform/linux_tray.cpp + ) + + target_link_libraries(lemonade-tray PRIVATE + nlohmann_json::nlohmann_json + httplib::httplib + Threads::Threads + ${TRAY_LIBS} + ) + + target_include_directories(lemonade-tray PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../include + ${TRAY_INCLUDE_DIRS} + ) + + target_compile_definitions(lemonade-tray PRIVATE + LEMONADE_TRAY + LEMONADE_TRAY_GUI + ${TRAY_DEFINITIONS} + ) + + # CLI11 linkage (mirror lemonade-server) + if(USE_SYSTEM_CLI11) + target_include_directories(lemonade-tray PRIVATE ${CLI11_INCLUDE_DIRS}) + else() + target_link_libraries(lemonade-tray PRIVATE CLI11::CLI11) + endif() + + # httplib linkage (mirror lemonade-server) + if(USE_SYSTEM_HTTPLIB AND HTTPLIB_INCLUDE_DIRS) + target_include_directories(lemonade-tray PRIVATE ${HTTPLIB_INCLUDE_DIRS}) + endif() + + # systemd support (mirror lemonade-server's existing block) + if(SYSTEMD_FOUND) + target_include_directories(lemonade-tray PRIVATE ${SYSTEMD_INCLUDE_DIRS}) + target_link_libraries(lemonade-tray PRIVATE ${SYSTEMD_LIBRARIES}) + target_compile_definitions(lemonade-tray PRIVATE + HAVE_SYSTEMD + LEMONADE_SYSTEMD_UNIT_NAME="${LEMONADE_SYSTEMD_UNIT_NAME}") + endif() + + set_target_properties(lemonade-tray PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" + ) + + install(TARGETS lemonade-tray RUNTIME DESTINATION bin) + + # Symlink in /usr/bin if not installing to /usr + if(NOT CMAKE_INSTALL_PREFIX STREQUAL "/usr") + install(CODE " + file(MAKE_DIRECTORY \"\$ENV{DESTDIR}/usr/bin\") + execute_process( + COMMAND ${CMAKE_COMMAND} -E create_symlink + ${CMAKE_INSTALL_PREFIX}/bin/lemonade-tray + \"\$ENV{DESTDIR}/usr/bin/lemonade-tray\" + ) + ") + endif() +endif() + # Platform-specific settings if(WIN32) # On Windows, make sure we have Unicode support @@ -304,7 +454,20 @@ if(WIN32) elseif(APPLE) message(STATUS "macOS tray support: PARTIAL (stub)") elseif(UNIX) - message(STATUS "Linux tray support: HEADLESS ONLY (use --no-tray)") - message(STATUS " No LGPL dependencies - permissively licensed only") + message(STATUS "lemonade-server: GTK-free (headless, daemon-friendly)") + if(ENABLE_LINUX_TRAY) + if(APP_INDICATOR_GLIB_FOUND) + message(STATUS "lemonade-tray: ENABLED (ayatana-appindicator-glib, GTK-free)") + else() + message(STATUS "lemonade-tray: ENABLED (GTK3 + AppIndicator3)") + endif() + if(LIBNOTIFY_FOUND) + message(STATUS " Notifications: ENABLED (libnotify)") + else() + message(STATUS " Notifications: DISABLED") + endif() + else() + message(STATUS "lemonade-tray: NOT BUILT (AppIndicator3 not found)") + endif() endif() message(STATUS "===============================================") diff --git a/src/cpp/tray/platform/linux_tray.cpp b/src/cpp/tray/platform/linux_tray.cpp index d696ac99d..8079f05fc 100644 --- a/src/cpp/tray/platform/linux_tray.cpp +++ b/src/cpp/tray/platform/linux_tray.cpp @@ -1,15 +1,521 @@ #if defined(__linux__) && !defined(__ANDROID__) #include "lemon_tray/platform/linux_tray.h" +#include "lemon/utils/aixlog.hpp" +#include #include -#include - -// Headless stub implementation for Linux -// This avoids LGPL dependencies (GTK3, libappindicator3, libnotify) -// Users should run with --no-tray flag on Linux +#include namespace lemon_tray { +#ifdef HAVE_APPINDICATOR + +// ── GLib-variant static helpers ─────────────────────────────────────────────────── + +#ifdef HAVE_AYATANA_APPINDICATOR_GLIB + +static void on_glib_action_activate(GSimpleAction* /*action*/, GVariant* /*value*/, gpointer data) { + auto* cb = static_cast*>(data); + if (cb && *cb) (*cb)(); +} + +static void on_glib_check_activate(GSimpleAction* action, GVariant* /*value*/, gpointer data) { + GVariant* state = g_action_get_state(G_ACTION(action)); + gboolean active = g_variant_get_boolean(state); + g_variant_unref(state); + g_action_change_state(G_ACTION(action), g_variant_new_boolean(!active)); + auto* cb = static_cast*>(data); + if (cb && *cb) (*cb)(); +} + +#else // GTK3-variant static helper + +static void on_menu_item_activate(GtkWidget* /*widget*/, gpointer data) { + auto* item = static_cast(data); + if (item && item->callback) { + item->callback(); + } +} + +#endif // HAVE_AYATANA_APPINDICATOR_GLIB + +// ── Constructor / Destructor ────────────────────────────────────────────────────── + +LinuxTray::LinuxTray() + : indicator_(nullptr) +#ifdef HAVE_AYATANA_APPINDICATOR_GLIB + , main_loop_(nullptr) + , g_menu_(nullptr) + , action_group_(nullptr) +#ifdef HAVE_DBUSMENU_GLIB + , dbusmenu_server_(nullptr) +#endif +#else + , gtk_menu_(nullptr) +#endif + , should_exit_(false) +{ +} + +LinuxTray::~LinuxTray() { + if (indicator_) { + g_object_unref(G_OBJECT(indicator_)); + } +#ifdef HAVE_AYATANA_APPINDICATOR_GLIB + if (main_loop_) { + g_main_loop_unref(main_loop_); + } + if (g_menu_) { + g_object_unref(g_menu_); + } + if (action_group_) { + g_object_unref(action_group_); + } + for (auto* cb : callbacks_) { + delete cb; + } +#ifdef HAVE_DBUSMENU_GLIB + if (dbusmenu_server_) { + g_object_unref(dbusmenu_server_); + } +#endif +#endif +} + +// ── initialize ──────────────────────────────────────────────────────────────────── + +bool LinuxTray::initialize(const std::string& app_name, const std::string& icon_path) { + app_name_ = app_name; + icon_path_ = icon_path; + +#ifdef HAVE_AYATANA_APPINDICATOR_GLIB + main_loop_ = g_main_loop_new(nullptr, FALSE); +#else + // The GTK3 Ayatana variant emits a runtime deprecation warning pointing to the glib variant. + // Suppress it since there is nothing actionable for users seeing our log output. + g_log_set_handler("libayatana-appindicator", G_LOG_LEVEL_WARNING, + [](const gchar*, GLogLevelFlags, const gchar*, gpointer) {}, nullptr); + + if (!gtk_init_check(0, nullptr)) { + std::cerr << "Failed to initialize GTK" << std::endl; + return false; + } +#endif + +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + indicator_ = app_indicator_new( + app_name.c_str(), + "indicator-messages", + APP_INDICATOR_CATEGORY_APPLICATION_STATUS + ); +#pragma GCC diagnostic pop + + if (!indicator_) { + std::cerr << "Failed to create AppIndicator" << std::endl; + return false; + } + +#ifdef HAVE_AYATANA_APPINDICATOR_GLIB + // Register an empty GMenuModel and DBusMenu before the indicator becomes visible. + // GNOME Shell queries com.canonical.dbusmenu immediately on discovery; both + // interfaces must be present before APP_INDICATOR_STATUS_ACTIVE is set. + g_menu_ = g_menu_new(); + action_group_ = g_simple_action_group_new(); + app_indicator_set_menu(indicator_, g_menu_); + app_indicator_set_actions(indicator_, action_group_); +#ifdef HAVE_DBUSMENU_GLIB + { + std::string clean = app_name; + for (auto& c : clean) + if (!g_ascii_isalnum(c)) c = '_'; + dbusmenu_server_ = dbusmenu_server_new(("/org/ayatana/appindicator/" + clean).c_str()); + DbusmenuMenuitem* root = dbusmenu_menuitem_new(); + dbusmenu_server_set_root(dbusmenu_server_, root); + g_object_unref(root); + } +#endif +#endif + + app_indicator_set_status(indicator_, APP_INDICATOR_STATUS_ACTIVE); + set_icon(icon_path); + +#ifdef HAVE_LIBNOTIFY + notify_init(app_name.c_str()); +#endif // HAVE_LIBNOTIFY + + return true; +} + +// ── run ─────────────────────────────────────────────────────────────────────────── + +void LinuxTray::run() { + if (ready_callback_) { + g_idle_add([](gpointer data) -> gboolean { + static_cast(data)->ready_callback_(); + return G_SOURCE_REMOVE; + }, this); + } + +#ifdef HAVE_AYATANA_APPINDICATOR_GLIB + g_main_loop_run(main_loop_); +#else + gtk_main(); +#endif + +#ifdef HAVE_LIBNOTIFY + if (notify_is_initted()) { + notify_uninit(); + } +#endif // HAVE_LIBNOTIFY +} + +// ── stop ────────────────────────────────────────────────────────────────────────── + +void LinuxTray::stop() { +#ifdef HAVE_AYATANA_APPINDICATOR_GLIB + GMainLoop* loop = main_loop_; + g_idle_add([](gpointer data) -> gboolean { + g_main_loop_quit(static_cast(data)); + return G_SOURCE_REMOVE; + }, loop); +#else + g_idle_add([](gpointer) -> gboolean { + gtk_main_quit(); + return G_SOURCE_REMOVE; + }, nullptr); +#endif +} + +// ── set_menu ───────────────────────────────────────────────────────────────────── + +void LinuxTray::set_menu(const Menu& menu) { + auto* menu_copy = new Menu(menu); + +#ifdef HAVE_AYATANA_APPINDICATOR_GLIB + g_idle_add([](gpointer data) -> gboolean { + auto* params = static_cast*>(data); + LinuxTray* self = params->first; + Menu* menu = params->second; + + // Build new resources first, then swap — keeps the indicator consistent. + GMenu* new_menu = g_menu_new(); + GSimpleActionGroup* new_group = g_simple_action_group_new(); + std::vector*> new_callbacks; + int action_id = 0; + self->build_g_menu(*menu, new_menu, new_group, action_id, new_callbacks); + + app_indicator_set_menu(self->indicator_, new_menu); + app_indicator_set_actions(self->indicator_, new_group); + +#ifdef HAVE_DBUSMENU_GLIB + if (self->dbusmenu_server_) { + DbusmenuMenuitem* root = dbusmenu_menuitem_new(); + self->build_dbusmenu(*menu, root, new_callbacks); + dbusmenu_server_set_root(self->dbusmenu_server_, root); + g_object_unref(root); + } +#endif + + // Release old resources now that the indicator holds refs to the new ones. + if (self->g_menu_) g_object_unref(self->g_menu_); + if (self->action_group_) g_object_unref(self->action_group_); + for (auto* cb : self->callbacks_) delete cb; + + self->g_menu_ = new_menu; + self->action_group_ = new_group; + self->callbacks_ = std::move(new_callbacks); + + delete menu; + delete params; + return G_SOURCE_REMOVE; + }, new std::pair(this, menu_copy)); +#else + g_idle_add([](gpointer data) -> gboolean { + auto* params = static_cast*>(data); + LinuxTray* self = params->first; + Menu* menu = params->second; + + if (self->gtk_menu_) { + gtk_widget_destroy(self->gtk_menu_); + } + + self->gtk_menu_ = gtk_menu_new(); + self->build_gtk_menu(*menu, self->gtk_menu_); + + gtk_widget_show_all(self->gtk_menu_); + app_indicator_set_menu(self->indicator_, GTK_MENU(self->gtk_menu_)); + + delete menu; + delete params; + return G_SOURCE_REMOVE; + }, new std::pair(this, menu_copy)); +#endif +} + +// ── build_g_menu (glib variant) ─────────────────────────────────────────────────── + +#ifdef HAVE_AYATANA_APPINDICATOR_GLIB + +void LinuxTray::build_g_menu(const Menu& menu, GMenu* parent, GSimpleActionGroup* actions, + int& action_id, std::vector*>& callbacks) { + GMenu* section = g_menu_new(); + + for (const auto& item : menu.items) { + if (item.is_separator) { + g_menu_append_section(parent, nullptr, G_MENU_MODEL(section)); + g_object_unref(section); + section = g_menu_new(); + continue; + } + + if (item.submenu) { + GMenu* submenu = g_menu_new(); + build_g_menu(*item.submenu, submenu, actions, action_id, callbacks); + + // g_menu_item_new_submenu() creates a pure submenu-parent item with no + // activatable action. Using g_menu_item_new() + g_menu_item_set_submenu() + // sets both "action" and "submenu" attributes, which causes the DBUSMENU + // serialiser in ayatana-appindicator-glib to discard the submenu link. + GMenuItem* menu_item = g_menu_item_new_submenu(item.text.c_str(), + G_MENU_MODEL(submenu)); + g_menu_append_item(section, menu_item); + g_object_unref(menu_item); + g_object_unref(submenu); + continue; + } + + std::string action_name = "action-" + std::to_string(action_id++); + std::string full_name = "indicator." + action_name; + + GSimpleAction* action = item.is_checkable + ? g_simple_action_new_stateful(action_name.c_str(), nullptr, + g_variant_new_boolean(item.checked ? TRUE : FALSE)) + : g_simple_action_new(action_name.c_str(), nullptr); + g_simple_action_set_enabled(action, item.enabled ? TRUE : FALSE); + + if (item.callback) { + auto* cb = new std::function(item.callback); + callbacks.push_back(cb); + if (item.is_checkable) { + g_signal_connect(action, "activate", G_CALLBACK(on_glib_check_activate), cb); + } else { + g_signal_connect(action, "activate", G_CALLBACK(on_glib_action_activate), cb); + } + } + + g_action_map_add_action(G_ACTION_MAP(actions), G_ACTION(action)); + g_object_unref(action); + + GMenuItem* menu_item = g_menu_item_new(item.text.c_str(), full_name.c_str()); + g_menu_append_item(section, menu_item); + g_object_unref(menu_item); + } + + if (g_menu_model_get_n_items(G_MENU_MODEL(section)) > 0) { + g_menu_append_section(parent, nullptr, G_MENU_MODEL(section)); + } + g_object_unref(section); +} + +#endif // HAVE_AYATANA_APPINDICATOR_GLIB + +// ── build_dbusmenu (dbusmenu-glib bridge) ───────────────────────────────────────── +// +// ayatana-appindicator-glib exports menus via org.gtk.Menus (GMenuModel), but +// GNOME Shell's AppIndicator extension only speaks com.canonical.dbusmenu. +// DbusmenuServer registers that interface on D-Bus at the same object path as +// the AppIndicator, so GNOME Shell finds it when it introspects the indicator. +// +// Ownership: each DbusmenuMenuitem is appended to its parent (which takes a ref), +// then unreffed here — net ref-count stays at 1, held by the parent. The root +// item is held by DbusmenuServer; replacing the root via dbusmenu_server_set_root() +// drops the old tree automatically. Callbacks are heap-allocated and tracked in +// `callbacks`; the caller deletes them when the menu is next replaced. + +#ifdef HAVE_DBUSMENU_GLIB + +void LinuxTray::build_dbusmenu(const Menu& menu, DbusmenuMenuitem* parent, + std::vector*>& callbacks) { + for (const auto& item : menu.items) { + DbusmenuMenuitem* mi = dbusmenu_menuitem_new(); + + if (item.is_separator) { + // The DBusMenu spec uses type="separator" for horizontal dividers. + dbusmenu_menuitem_property_set(mi, DBUSMENU_MENUITEM_PROP_TYPE, "separator"); + } else { + dbusmenu_menuitem_property_set(mi, DBUSMENU_MENUITEM_PROP_LABEL, item.text.c_str()); + dbusmenu_menuitem_property_set_bool(mi, DBUSMENU_MENUITEM_PROP_ENABLED, + item.enabled ? TRUE : FALSE); + if (item.is_checkable) { + // Renders as a checkmark item; TOGGLE_STATE reflects the current checked value. + dbusmenu_menuitem_property_set(mi, DBUSMENU_MENUITEM_PROP_TOGGLE_TYPE, + DBUSMENU_MENUITEM_TOGGLE_CHECK); + dbusmenu_menuitem_property_set_int(mi, DBUSMENU_MENUITEM_PROP_TOGGLE_STATE, + item.checked ? DBUSMENU_MENUITEM_TOGGLE_STATE_CHECKED + : DBUSMENU_MENUITEM_TOGGLE_STATE_UNCHECKED); + } + if (item.callback) { + // The lambda is stateless so +[] converts it to a plain function pointer + // suitable for GSignal. The heap-allocated cb outlives the signal because + // it is deleted by the caller only after dbusmenu_server_set_root() drops + // the old item tree (and thereby disconnects all signals on it). + auto* cb = new std::function(item.callback); + callbacks.push_back(cb); + g_signal_connect(mi, DBUSMENU_MENUITEM_SIGNAL_ITEM_ACTIVATED, + G_CALLBACK(+[](DbusmenuMenuitem*, guint, gpointer data) { + (*static_cast*>(data))(); + }), cb); + } + if (item.submenu) { + // CHILD_DISPLAY_SUBMENU tells the shell to render an arrow/flyout. + dbusmenu_menuitem_property_set(mi, DBUSMENU_MENUITEM_PROP_CHILD_DISPLAY, + DBUSMENU_MENUITEM_CHILD_DISPLAY_SUBMENU); + build_dbusmenu(*item.submenu, mi, callbacks); + } + } + + dbusmenu_menuitem_child_append(parent, mi); + g_object_unref(mi); + } +} + +#endif // HAVE_DBUSMENU_GLIB + +// ── build_gtk_menu (GTK3 variant) ───────────────────────────────────────────────── + +#ifndef HAVE_AYATANA_APPINDICATOR_GLIB + +void LinuxTray::build_gtk_menu(const Menu& menu, GtkWidget* parent_menu) { + for (const auto& item : menu.items) { + GtkWidget* gtk_item = nullptr; + + if (item.is_separator) { + gtk_item = gtk_separator_menu_item_new(); + } else if (item.submenu) { + gtk_item = gtk_menu_item_new_with_label(item.text.c_str()); + GtkWidget* submenu = gtk_menu_new(); + build_gtk_menu(*item.submenu, submenu); + gtk_menu_item_set_submenu(GTK_MENU_ITEM(gtk_item), submenu); + } else if (item.is_checkable) { + gtk_item = gtk_check_menu_item_new_with_label(item.text.c_str()); + gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(gtk_item), item.checked ? TRUE : FALSE); + } else { + gtk_item = gtk_menu_item_new_with_label(item.text.c_str()); + } + + gtk_widget_set_sensitive(gtk_item, item.enabled); + + if (item.callback) { + auto* persistent_item = new MenuItem(item); + g_object_set_data_full(G_OBJECT(gtk_item), "menu-item-data", persistent_item, + [](gpointer data) { delete static_cast(data); }); + g_signal_connect(gtk_item, "activate", G_CALLBACK(on_menu_item_activate), persistent_item); + } + + gtk_menu_shell_append(GTK_MENU_SHELL(parent_menu), gtk_item); + } +} + +#endif // !HAVE_AYATANA_APPINDICATOR_GLIB + +// ── update_menu ─────────────────────────────────────────────────────────────────── + +void LinuxTray::update_menu() { + // No-op, handled by set_menu +} + +// ── show_notification ───────────────────────────────────────────────────────────── + +void LinuxTray::show_notification(const std::string& title, const std::string& message, NotificationType type) { + struct NotifData { std::string title, message; NotificationType type; }; + auto* d = new NotifData{title, message, type}; + + g_idle_add([](gpointer data) -> gboolean { + auto* d = static_cast(data); + +#ifdef HAVE_LIBNOTIFY + NotifyNotification* n = notify_notification_new(d->title.c_str(), d->message.c_str(), nullptr); + notify_notification_set_timeout(n, 3000); + NotifyUrgency urgency = (d->type == NotificationType::ERROR) ? NOTIFY_URGENCY_CRITICAL : + (d->type == NotificationType::WARNING) ? NOTIFY_URGENCY_NORMAL : + NOTIFY_URGENCY_LOW; + notify_notification_set_urgency(n, urgency); + notify_notification_show(n, nullptr); + g_object_unref(G_OBJECT(n)); +#else // !HAVE_LIBNOTIFY + std::cout << "[Notification] " << d->title << ": " << d->message << std::endl; +#endif // HAVE_LIBNOTIFY + + delete d; + return G_SOURCE_REMOVE; + }, d); +} + +// ── set_icon ────────────────────────────────────────────────────────────────────── + +void LinuxTray::set_icon(const std::string& icon_path) { + auto* params = new std::pair(this, icon_path); + + g_idle_add([](gpointer data) -> gboolean { + namespace fs = std::filesystem; + auto* params = static_cast*>(data); + LinuxTray* self = params->first; + fs::path icon{params->second}; + + if (self->indicator_) { + if (icon.is_absolute()) { + std::string stem = icon.stem().string(); + if (params->second.find("/icons/hicolor/") != std::string::npos) { + // Installed in the hicolor theme; AppIndicator/SNI resolves it by name. +#ifdef HAVE_AYATANA_APPINDICATOR_GLIB + app_indicator_set_icon(self->indicator_, stem.c_str(), "Lemonade"); +#else + app_indicator_set_icon_full(self->indicator_, stem.c_str(), "Lemonade"); +#endif + } else { + // Dev build: set the parent directory as an extra icon theme path. + // Note: AppIndicator expects XDG icon theme structure under this path, + // so a flat .ico file won't resolve; the indicator will fall back to + // its default icon. This is acceptable for development builds. + app_indicator_set_icon_theme_path(self->indicator_, icon.parent_path().c_str()); +#ifdef HAVE_AYATANA_APPINDICATOR_GLIB + app_indicator_set_icon(self->indicator_, stem.c_str(), "Lemonade"); +#else + app_indicator_set_icon_full(self->indicator_, stem.c_str(), "Lemonade"); +#endif + } + } else if (!params->second.empty()) { + // Bare icon name — resolved via the system icon theme. +#ifdef HAVE_AYATANA_APPINDICATOR_GLIB + app_indicator_set_icon(self->indicator_, params->second.c_str(), "Lemonade"); +#else + app_indicator_set_icon_full(self->indicator_, params->second.c_str(), "Lemonade"); +#endif + } + } + + delete params; + return G_SOURCE_REMOVE; + }, params); +} + +// ── set_tooltip ─────────────────────────────────────────────────────────────────── + +void LinuxTray::set_tooltip(const std::string& /*tooltip*/) { + // AppIndicator does not expose a tooltip API. +} + +// ── set_ready_callback ──────────────────────────────────────────────────────────── + +void LinuxTray::set_ready_callback(std::function callback) { + ready_callback_ = callback; +} + +#else // !HAVE_APPINDICATOR + +// ── Headless implementations ────────────────────────────────────────────────────── + LinuxTray::LinuxTray() : should_exit_(false) { @@ -82,6 +588,9 @@ void LinuxTray::set_ready_callback(std::function callback) { ready_callback_ = callback; } +#endif // HAVE_APPINDICATOR + + } // namespace lemon_tray #endif // __linux__ && !__ANDROID__ diff --git a/src/cpp/tray/tray_app.cpp b/src/cpp/tray/tray_app.cpp index 799646aa8..a6e22ae47 100644 --- a/src/cpp/tray/tray_app.cpp +++ b/src/cpp/tray/tray_app.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #ifdef _WIN32 #include // Must come before windows.h @@ -120,9 +121,17 @@ static nlohmann::json build_launch_recipe_options(const lemon::TrayConfig& tray_ } #if !defined(_WIN32) -// Check if systemd is running and a unit is active -static bool is_systemd_service_active(const char* unit_name) { #ifdef HAVE_SYSTEMD +enum class SystemdBusScope { System, User }; + +struct SystemdUnitInfo { + std::string name; + SystemdBusScope scope = SystemdBusScope::System; + explicit operator bool() const { return !name.empty(); } +}; + +// Internal: check if a unit is active on a specific D-Bus (system or user session) +static bool is_systemd_service_active_on_bus(const char* unit_name, SystemdBusScope scope) { if (!unit_name || unit_name[0] == '\0') { return false; } @@ -135,7 +144,9 @@ static bool is_systemd_service_active(const char* unit_name) { sd_bus_message* reply = nullptr; sd_bus_error error = SD_BUS_ERROR_NULL; - int r = sd_bus_open_system(&bus); + int r = (scope == SystemdBusScope::User) + ? sd_bus_open_user(&bus) + : sd_bus_open_system(&bus); if (r < 0 || !bus) { sd_bus_error_free(&error); return false; @@ -197,15 +208,23 @@ static bool is_systemd_service_active(const char* unit_name) { sd_bus_unref(bus); return is_active; +} +#endif + +// Check if systemd is running and a unit is active (system or user bus) +static bool is_systemd_service_active(const char* unit_name) { +#ifdef HAVE_SYSTEMD + return is_systemd_service_active_on_bus(unit_name, SystemdBusScope::System) || + is_systemd_service_active_on_bus(unit_name, SystemdBusScope::User); #else (void)unit_name; return false; #endif } -// Get systemd service MainPID (returns 0 if unavailable) -static int get_systemd_service_main_pid(const char* unit_name) { +// Internal: get MainPID of a unit on a specific D-Bus (returns 0 if unavailable) #ifdef HAVE_SYSTEMD +static int get_systemd_service_main_pid_on_bus(const char* unit_name, SystemdBusScope scope) { if (!unit_name || unit_name[0] == '\0') { return 0; } @@ -218,7 +237,9 @@ static int get_systemd_service_main_pid(const char* unit_name) { sd_bus_message* reply = nullptr; sd_bus_error error = SD_BUS_ERROR_NULL; - int r = sd_bus_open_system(&bus); + int r = (scope == SystemdBusScope::User) + ? sd_bus_open_user(&bus) + : sd_bus_open_system(&bus); if (r < 0 || !bus) { sd_bus_error_free(&error); return 0; @@ -275,6 +296,15 @@ static int get_systemd_service_main_pid(const char* unit_name) { sd_bus_unref(bus); return static_cast(main_pid); +} +#endif + +// Get systemd service MainPID (system bus first, then user bus) +static int get_systemd_service_main_pid(const char* unit_name) { +#ifdef HAVE_SYSTEMD + int pid = get_systemd_service_main_pid_on_bus(unit_name, SystemdBusScope::System); + if (pid > 0) return pid; + return get_systemd_service_main_pid_on_bus(unit_name, SystemdBusScope::User); #else (void)unit_name; return 0; @@ -306,25 +336,122 @@ static const char* kSystemdUnitNames[] = { "snap.lemonade-server.daemon.service" }; -static const char* get_active_systemd_unit_name() { +// Returns active unit info (system bus has priority over user bus) +#ifdef HAVE_SYSTEMD +static SystemdUnitInfo get_active_systemd_unit_info() { for (const auto* unit_name : kSystemdUnitNames) { - if (is_systemd_service_active(unit_name)) { - return unit_name; + if (is_systemd_service_active_on_bus(unit_name, SystemdBusScope::System)) + return {unit_name, SystemdBusScope::System}; + if (is_systemd_service_active_on_bus(unit_name, SystemdBusScope::User)) + return {unit_name, SystemdBusScope::User}; + } + return {}; +} +#endif + +static bool is_any_systemd_service_active() { +#ifdef HAVE_SYSTEMD + return static_cast(get_active_systemd_unit_info()); +#else + return false; +#endif +} + +// Returns which bus scope owns a unit file, or nullopt if no unit file found +#ifdef HAVE_SYSTEMD +static std::optional get_unit_file_scope(const char* unit_name) { + if (!unit_name || unit_name[0] == '\0') return std::nullopt; + for (const auto* d : {"/etc/systemd/system/", "/usr/lib/systemd/system/", "/lib/systemd/system/"}) { + if (fs::exists(std::string(d) + unit_name)) return SystemdBusScope::System; + } + const char* home = getenv("HOME"); + if (home && fs::exists(std::string(home) + "/.config/systemd/user/" + unit_name)) { + return SystemdBusScope::User; + } + return std::nullopt; +} +#endif + +// Check if a systemd unit file exists on disk (even if inactive/disabled) +static bool systemd_unit_file_exists(const char* unit_name) { + if (!unit_name || unit_name[0] == '\0') return false; +#ifdef HAVE_SYSTEMD + return get_unit_file_scope(unit_name).has_value(); +#else + std::vector dirs = { + "/etc/systemd/system/", + "/usr/lib/systemd/system/", + "/lib/systemd/system/", + }; + const char* home = getenv("HOME"); + if (home) dirs.push_back(std::string(home) + "/.config/systemd/user/"); + for (const auto& d : dirs) { + if (fs::exists(d + unit_name)) return true; + } + return false; +#endif +} + +// Returns the first known systemd unit that is active or has an installed unit file +#ifdef HAVE_SYSTEMD +static SystemdUnitInfo get_first_known_systemd_unit() { + for (const auto* unit : kSystemdUnitNames) { + // Active check takes priority over file-based scope detection + for (auto scope : {SystemdBusScope::System, SystemdBusScope::User}) { + if (is_systemd_service_active_on_bus(unit, scope)) + return {unit, scope}; + } + auto file_scope = get_unit_file_scope(unit); + if (file_scope) return {unit, *file_scope}; + } + return {}; +} +#else +static const char* get_first_known_systemd_unit() { + for (const auto* unit : kSystemdUnitNames) { + if (is_systemd_service_active(unit) || systemd_unit_file_exists(unit)) { + return unit; } } return nullptr; } +#endif -static bool is_any_systemd_service_active() { - return get_active_systemd_unit_name() != nullptr; +// Start or stop a systemd service. +#ifdef HAVE_SYSTEMD +static bool systemd_control_service(const SystemdUnitInfo& unit, bool start) { + if (!unit) return false; + std::string action = start ? "start" : "stop"; + if (unit.scope == SystemdBusScope::User) { + std::string cmd = "systemctl --user " + action + " " + unit.name + " 2>/dev/null"; + return system(cmd.c_str()) == 0; + } + // System service: try direct, fall back to pkexec + std::string cmd = "systemctl " + action + " " + unit.name + " 2>/dev/null"; + if (system(cmd.c_str()) == 0) return true; + cmd = "pkexec systemctl " + action + " " + unit.name; + return system(cmd.c_str()) == 0; +} +#else +static bool systemd_control_service(const char* unit_name, bool start) { + if (!unit_name) return false; + std::string action = start ? "start" : "stop"; + std::string cmd = "systemctl " + action + " " + std::string(unit_name) + " 2>/dev/null"; + if (system(cmd.c_str()) == 0) return true; + // Fall back to pkexec (graphical polkit dialog for system services) + cmd = "pkexec systemctl " + action + " " + std::string(unit_name); + return system(cmd.c_str()) == 0; } +#endif static int get_systemd_any_service_main_pid() { - const char* unit_name = get_active_systemd_unit_name(); - if (!unit_name) { - return 0; - } - return get_systemd_service_main_pid(unit_name); +#ifdef HAVE_SYSTEMD + auto unit = get_active_systemd_unit_info(); + if (!unit) return 0; + return get_systemd_service_main_pid_on_bus(unit.name.c_str(), unit.scope); +#else + return 0; +#endif } static bool is_systemd_any_service_active_other_process() { @@ -736,28 +863,66 @@ int TrayApp::run() { } } } -#else - // Other platforms: Tray-only mode - just show tray connected to existing server - // Check if server is already running - auto [pid, running_port] = get_server_info(); - if (running_port == 0) { - std::cerr << "Error: No Lemonade Server is currently running.\n" - << "Start the server first with: lemonade-server serve\n" - << "Or run: lemonade-server serve --no-tray" << std::endl; - return 1; - } - - // Create server manager to communicate with running server - server_manager_ = std::make_unique(server_config_.host, server_config_.port); - server_manager_->set_port(running_port); - server_config_.port = running_port; // Update config to match running server - - server_config_.host = normalize_connect_host(server_config_.host); - - std::cout << "Connected to Lemonade Server on port " << running_port << std::endl; +#elif !defined(_WIN32) + // Linux/Unix: Try to connect to running server, or start one + { + auto [pid, running_port] = get_server_info(); + if (running_port != 0) { + // Server is already running — connect to it + // Track PID only for non-systemd processes (systemd ones are tracked by unit name) + if (!is_any_systemd_service_active()) { + external_server_pid_ = pid; + } + server_manager_ = std::make_unique(server_config_.host, running_port); + server_manager_->set_port(running_port); + server_config_.port = running_port; + if (server_config_.host.empty() || server_config_.host == "0.0.0.0") { + server_config_.host = "localhost"; + } + std::cout << "Connected to Lemonade Server on port " << running_port << std::endl; + } else { + // No server running — try to start one + bool started = false; - // Continue to tray initialization below (skip server startup) +#ifdef HAVE_SYSTEMD + auto unit = get_first_known_systemd_unit(); + if (unit) { + std::cout << "Starting server via systemd: " << unit.name << std::endl; + if (systemd_control_service(unit, /*start=*/true)) { + // Wait up to 10 seconds for server to appear + for (int i = 0; i < 10 && !started; ++i) { + std::this_thread::sleep_for(std::chrono::seconds(1)); + auto [spid, sport] = get_server_info(); + if (sport != 0) { + server_manager_ = std::make_unique(server_config_.host, sport); + server_manager_->set_port(sport); + server_config_.port = sport; + if (server_config_.host.empty() || server_config_.host == "0.0.0.0") { + server_config_.host = "localhost"; + } + started = true; + } + } + } + } #endif + + if (!started) { + // No systemd unit available — spawn lemonade-router directly + std::cout << "Starting Lemonade Server..." << std::endl; + server_manager_ = std::make_unique(server_config_.host, server_config_.port); + if (!start_server()) { + std::cerr << "Error: Failed to start Lemonade Server" << std::endl; + return 1; + } + process_owns_server_ = true; + if (server_config_.host.empty() || server_config_.host == "0.0.0.0") { + server_config_.host = "localhost"; + } + } + } + } +#endif // !__APPLE__ && !_WIN32 } else { std::cerr << "Internal Error: Unhandled command '" << tray_config_.command << "'\n" << std::endl; return 1; @@ -884,19 +1049,80 @@ int TrayApp::run() { } else { LOG(DEBUG, "TrayApp") << "Icon found at: " << icon_path << std::endl; } +#elif defined(__linux__) + // On Linux, search XDG data directories for the installed icon first. + // The SVG in the hicolor icon theme is the preferred format for AppIndicator3. + { + std::vector data_dirs; + // XDG_DATA_HOME takes priority (defaults to ~/.local/share) + const char* xdg_data_home = getenv("XDG_DATA_HOME"); + if (xdg_data_home && xdg_data_home[0]) { + data_dirs.push_back(xdg_data_home); + } else { + const char* home = getenv("HOME"); + if (home && home[0]) data_dirs.push_back(std::string(home) + "/.local/share"); + } + // Then XDG_DATA_DIRS (system-wide locations) + const char* xdg_data_dirs = getenv("XDG_DATA_DIRS"); + if (xdg_data_dirs && xdg_data_dirs[0]) { + std::istringstream ss(xdg_data_dirs); + std::string d; + while (std::getline(ss, d, ':')) { + if (!d.empty()) data_dirs.push_back(d); + } + } else { + data_dirs.push_back("/usr/local/share"); + data_dirs.push_back("/usr/share"); + } + for (const auto& d : data_dirs) { + // Preferred: SVG in the hicolor icon theme (installed by RPM/DEB) + auto svg = fs::path(d) / "icons/hicolor/scalable/apps/ai.lemonade_server.Lemonade.svg"; + if (fs::exists(svg)) { + icon_path = svg.string(); + LOG(DEBUG, "TrayApp") << "Icon found in hicolor theme: " << icon_path << std::endl; + break; + } + // Fallback: favicon.ico in the lemonade-server share directory + auto ico = fs::path(d) / "lemonade-server/resources/static/favicon.ico"; + if (fs::exists(ico)) { + icon_path = ico.string(); + LOG(DEBUG, "TrayApp") << "Icon found in share dir: " << icon_path << std::endl; + break; + } + } + } + + if (icon_path.empty()) { + // Development build: try relative to CWD and executable directory + fs::path exe_path = fs::path(server_binary_).parent_path(); + std::vector dev_paths = { + "resources/static/favicon.ico", + exe_path / "resources" / "static" / "favicon.ico", + exe_path / "resources" / "favicon.ico", + }; + for (const auto& p : dev_paths) { + if (fs::exists(p)) { + icon_path = p.string(); + LOG(DEBUG, "TrayApp") << "Icon found (dev path): " << icon_path << std::endl; + break; + } + } + } + + if (icon_path.empty()) { + LOG(WARNING, "TrayApp") << "Icon not found at any known location; will use default icon" << std::endl; + } #else - // On other platforms, find icon file path + // On Windows and other platforms, find icon file path icon_path = "resources/static/favicon.ico"; LOG(DEBUG, "TrayApp") << "Checking icon at: " << fs::absolute(icon_path).string() << std::endl; - if (!fs::exists(icon_path)) { // Try relative to executable directory fs::path exe_path = fs::path(server_binary_).parent_path(); icon_path = (exe_path / "resources" / "static" / "favicon.ico").string(); LOG(DEBUG, "TrayApp") << "Icon not found, trying: " << icon_path << std::endl; - // If still not found, try without static subdir (fallback) if (!fs::exists(icon_path)) { icon_path = (exe_path / "resources" / "favicon.ico").string(); @@ -904,7 +1130,6 @@ int TrayApp::run() { } } - if (fs::exists(icon_path)) { LOG(DEBUG, "TrayApp") << "Icon found at: " << icon_path << std::endl; } else { @@ -931,9 +1156,7 @@ int TrayApp::run() { // This allows us to handle Ctrl+C cleanly even when tray is running LOG(DEBUG, "TrayApp") << "Starting signal monitor thread..." << std::endl; signal_monitor_thread_ = std::thread([this]() { - #ifdef __APPLE__ auto last_tick = std::chrono::steady_clock::now(); - #endif while (!stop_signal_monitor_ && !should_exit_) { fd_set readfds; FD_ZERO(&readfds); @@ -941,18 +1164,13 @@ int TrayApp::run() { struct timeval tv = {0, 100000}; // 100ms timeout int result = select(signal_pipe_[0] + 1, &readfds, nullptr, nullptr, &tv); - #ifdef __APPLE__ - // Check if 5 seconds have passed, refresh menu + // Check if 5 seconds have passed, refresh menu (both macOS and Linux) auto now = std::chrono::steady_clock::now(); if (std::chrono::duration_cast(now - last_tick).count() >= 5) { - LOG(DEBUG, "TrayApp") << "Checking if menu needs refresh" << std::endl; refresh_menu(); - - // Reset the tracker last_tick = now; } - #endif if (result > 0 && FD_ISSET(signal_pipe_[0], &readfds)) { // Signal received (SIGINT from Ctrl+C) char sig; @@ -1976,6 +2194,27 @@ static bool is_process_alive(int pid) { #endif } +#ifndef _WIN32 +// Poll until the SingleInstance lock for lemonade-router is acquirable (i.e. no process holds it). +// Returns true when the lock is free, false if timeout_ms elapses. +// Acquires and immediately releases the lock each probe, so it doesn't interfere with the +// real lock acquisition inside lemonade-router. +static bool wait_for_router_lock_free(int timeout_ms) { + std::string lock_file = lemon::utils::get_runtime_dir() + "/lemonade_Router.lock"; + auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); + while (std::chrono::steady_clock::now() < deadline) { + int fd = open(lock_file.c_str(), O_RDONLY | O_CLOEXEC); + if (fd == -1) return true; // File doesn't exist — lock is free + bool free = (flock(fd, LOCK_EX | LOCK_NB) == 0); + if (free) flock(fd, LOCK_UN); + close(fd); + if (free) return true; + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + return false; +} +#endif + int TrayApp::execute_stop_command() { auto [pid, port] = get_server_info(); @@ -1985,12 +2224,13 @@ int TrayApp::execute_stop_command() { } // On Linux, check if server is managed by systemd and warn user -#ifndef _WIN32 - const char* active_systemd_unit = get_active_systemd_unit_name(); +#if !defined(_WIN32) && defined(HAVE_SYSTEMD) + auto active_systemd_unit = get_active_systemd_unit_info(); if (active_systemd_unit) { std::cerr << "Error: Lemonade Server is managed by systemd." << std::endl; - std::string unit_name(active_systemd_unit); - std::cerr << "Please use: sudo systemctl stop " << unit_name << std::endl; + std::cerr << "Please use: systemctl" + << (active_systemd_unit.scope == SystemdBusScope::User ? " --user" : "") + << " stop " << active_systemd_unit.name << std::endl; std::cerr << "Instead of: lemonade-server stop" << std::endl; return 1; } @@ -2422,6 +2662,11 @@ void TrayApp::build_menu() { // Cache current state for refresh comparisons last_menu_loaded_models_ = get_all_loaded_models(); last_menu_available_models_ = get_downloaded_models(); + if (server_manager_) { + try { server_manager_->get_health(); last_menu_server_reachable_ = true; } catch (...) { last_menu_server_reachable_ = false; } + } else { + last_menu_server_reachable_ = false; + } } void TrayApp::refresh_menu() { @@ -2435,6 +2680,16 @@ void TrayApp::refresh_menu() { } bool TrayApp::menu_needs_refresh() { + // Check server reachability change + bool server_reachable = false; + if (server_manager_) { + try { server_manager_->get_health(); server_reachable = true; } catch (...) {} + } + if (server_reachable != last_menu_server_reachable_) { + last_menu_server_reachable_ = server_reachable; + return true; + } + // Check if loaded models have changed auto current_loaded = get_all_loaded_models(); if (current_loaded != last_menu_loaded_models_) { @@ -2587,7 +2842,74 @@ Menu TrayApp::create_menu() { build_menu(); })); } -#endif +#elif defined(__linux__) + // Linux server management + { + bool server_reachable = false; + if (server_manager_) { + try { server_manager_->get_health(); server_reachable = true; } catch (...) {} + } + +#ifdef HAVE_SYSTEMD + auto active_unit = get_active_systemd_unit_info(); + if (active_unit) { + // Server is actively managed by systemd — delegate stop to systemd + menu.add_item(MenuItem::Action("Stop Service", [this, active_unit]() { + std::thread([this, active_unit]() { + if (!systemd_control_service(active_unit, /*start=*/false)) { + show_notification("Stop Failed", + std::string("Could not stop service. Try: systemctl") + + (active_unit.scope == SystemdBusScope::User ? " --user" : "") + + " stop " + active_unit.name); + } + build_menu(); + }).detach(); + })); + } else { + // systemd unit not active — check if it exists for Start option + auto known_unit = get_first_known_systemd_unit(); + if (known_unit && !server_reachable) { + menu.add_item(MenuItem::Action("Start Service", [this, known_unit]() { + std::thread([this, known_unit]() { + if (systemd_control_service(known_unit, /*start=*/true)) { + for (int i = 0; i < 10; ++i) { + std::this_thread::sleep_for(std::chrono::seconds(1)); + auto [pid, port] = get_server_info(); + if (port != 0) { + server_manager_ = std::make_unique(server_config_.host, port); + server_manager_->set_port(port); + server_config_.port = port; + break; + } + } + } else { + show_notification("Start Failed", + std::string("Could not start service. Try: systemctl") + + (known_unit.scope == SystemdBusScope::User ? " --user" : "") + + " start " + known_unit.name); + } + build_menu(); + }).detach(); + })); + } else if (server_reachable && process_owns_server_) { + menu.add_item(MenuItem::Action("Stop Server", [this]() { on_stop_server(); })); + } else if (server_reachable && !process_owns_server_) { + menu.add_item(MenuItem::Action("Stop Server", [this]() { on_stop_external_server(); })); + } else if (!server_reachable) { + menu.add_item(MenuItem::Action("Start Server", [this]() { on_start_server(); })); + } + } +#else // !HAVE_SYSTEMD + if (!server_reachable) { + menu.add_item(MenuItem::Action("Start Server", [this]() { on_start_server(); })); + } else if (process_owns_server_) { + menu.add_item(MenuItem::Action("Stop Server", [this]() { on_stop_server(); })); + } else { + menu.add_item(MenuItem::Action("Stop Server", [this]() { on_stop_external_server(); })); + } +#endif // HAVE_SYSTEMD + } +#endif // __APPLE__ / __linux__ // Port submenu auto port_submenu = std::make_shared(); @@ -2618,7 +2940,8 @@ Menu TrayApp::create_menu() { {"256K", 262144} }; for (const auto& [label, size] : ctx_sizes) { - bool is_current = (size == server_config_.recipe_options["ctx_size"]); + bool is_current = server_config_.recipe_options.contains("ctx_size") && + (size == server_config_.recipe_options["ctx_size"]); ctx_submenu->add_item(MenuItem::Checkable( "Context size " + label, [this, size = size]() { on_change_context_size(size); }, @@ -2641,6 +2964,62 @@ Menu TrayApp::create_menu() { // Menu action implementations +#ifndef _WIN32 +void TrayApp::on_start_server() { + if (!server_manager_) { + server_manager_ = std::make_unique(server_config_.host, server_config_.port); + } + std::thread([this]() { + // Wait for the previous router process to fully release its lock before starting a new one. + // The flock may still appear held briefly after the process disappears from /proc. + if (!wait_for_router_lock_free(3000)) { + server_manager_.reset(); + show_notification("Start Failed", "Failed to start Lemonade Server (previous instance still running)"); + build_menu(); + return; + } + if (start_server()) { + process_owns_server_ = true; + if (server_config_.host.empty() || server_config_.host == "0.0.0.0") { + server_config_.host = "localhost"; + } + show_notification("Server Started", "Lemonade Server is now running"); + } else { + server_manager_.reset(); + show_notification("Start Failed", "Failed to start Lemonade Server"); + } + build_menu(); + }).detach(); +} + +void TrayApp::on_stop_server() { + std::thread([this]() { + stop_server(); + process_owns_server_ = false; + server_manager_.reset(); + show_notification("Server Stopped", "Lemonade Server has been stopped"); + build_menu(); + }).detach(); +} + +void TrayApp::on_stop_external_server() { + int pid = external_server_pid_; + if (pid <= 0) return; + std::thread([this, pid]() { + kill(pid, SIGTERM); + for (int i = 0; i < 50; ++i) { + if (!is_process_alive(pid)) break; + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + if (is_process_alive(pid)) kill(pid, SIGKILL); + external_server_pid_ = 0; + server_manager_.reset(); + show_notification("Server Stopped", "Lemonade Server has been stopped"); + build_menu(); + }).detach(); +} +#endif + void TrayApp::on_load_model(const std::string& model_name) { // CRITICAL: Make a copy IMMEDIATELY since model_name is a reference that gets invalidated // when build_menu() destroys the old menu (which destroys the lambda that captured the model) @@ -3213,6 +3592,7 @@ void TrayApp::show_notification(const std::string& title, const std::string& mes } std::string TrayApp::get_loaded_model() { + if (!server_manager_) return ""; try { auto health = server_manager_->get_health(); @@ -3232,6 +3612,7 @@ std::string TrayApp::get_loaded_model() { std::vector TrayApp::get_all_loaded_models() { std::vector loaded_models; + if (!server_manager_) return loaded_models; try { auto health = server_manager_->get_health(); @@ -3260,6 +3641,7 @@ std::vector TrayApp::get_all_loaded_models() { } std::vector TrayApp::get_downloaded_models() { + if (!server_manager_) return {}; try { auto models_json = server_manager_->get_models(); std::vector models;