diff --git a/CMakeLists.txt b/CMakeLists.txt index 3ee7050..9e1cd1d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -16,6 +16,7 @@ list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake") option(ENABLE_FLAT_PACKAGE "Creates a \"flat\" install layout with the executable, configuration file(s) and preset/texture dirs directly in the install prefix." OFF) option(ENABLE_INSTALL_BDEPS "Installs all shared libraries projectMSDL requires to run. On some platforms, CMake 3.31 or higher is required for this to work!" OFF) option(ENABLE_GLES "Build the application to use OpenGL ES instead of Core GL. May not work on all platforms and requires CMake 3.27 or higher." OFF) +option(ENABLE_CONTROL_SOCKET "Build the UNIX-domain control socket for external application control. UNIX platforms only." OFF) set(PROJECTMSDL_PROPERTIES_FILENAME "projectMSDL.properties") @@ -133,6 +134,7 @@ if(NOT PACKAGING_CONFIG_FILE STREQUAL "") include(${PACKAGING_CONFIG_FILE}) endif() +message(STATUS "Control socket: ${ENABLE_CONTROL_SOCKET}") message(STATUS "SDL version: ${SDL2_VERSION}") message(STATUS "Poco version: ${Poco_VERSION}") message(STATUS "projectM version: ${projectM4_VERSION}") diff --git a/examples/vj-control b/examples/vj-control new file mode 100755 index 0000000..96f961f --- /dev/null +++ b/examples/vj-control @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# +# vj-control.sh — drive projectMSDL over its UNIX control socket and render a +# 3-line button face (icon / name / status) suitable for a Stream Deck. +# +# Usage: +# vj-control next Advance to the next preset. +# vj-control last Return to the previously displayed preset. +# vj-control random Jump to a random preset. +# vj-control shuffle Toggle shuffle mode. +# vj-control lock Toggle the preset lock. +# +# Output is always exactly three lines on stdout: +# 1. an operation icon (Nerd Font glyph; for shuffle/lock it reflects the new state) +# 2. the function name +# 3. a status icon — ok (check) or error (cross) +# +# Environment: +# VJ_CONTROL_SOCKET Path to the control socket. Defaults to +# $XDG_RUNTIME_DIR/projectMSDL.sock, then /tmp/projectMSDL.sock. + +set -u + +# --- Nerd Font glyphs (FontAwesome range, present in any Nerd Font) ----------- +ICON_NEXT=$'' # step-forward +ICON_LAST=$'' # rotate-left / undo +ICON_RANDOM=$'' # dice (jump to a random preset) +ICON_SHUFFLE_ON=$'' # random +ICON_SHUFFLE_OFF=$'' # long-arrow-right (sequential) +ICON_LOCK_ON=$'' # lock (closed) +ICON_LOCK_OFF=$'' # unlock (open) +ICON_OFFLINE=$'' # broken chain (cannot reach projectMSDL) + +# --- Result-line status icons ------------------------------------------------- +ICON_OK=$'' # check-circle +ICON_ERR=$'' # times-circle + +# --- Socket resolution -------------------------------------------------------- +resolve_socket() { + if [[ -n "${VJ_CONTROL_SOCKET:-}" ]]; then + printf '%s' "$VJ_CONTROL_SOCKET" + return + fi + if [[ -n "${XDG_RUNTIME_DIR:-}" && -S "$XDG_RUNTIME_DIR/projectMSDL.sock" ]]; then + printf '%s' "$XDG_RUNTIME_DIR/projectMSDL.sock" + return + fi + printf '%s' "/tmp/projectMSDL.sock" +} + +SOCK="$(resolve_socket)" + +# --- Dependencies ------------------------------------------------------------- +need() { + command -v "$1" >/dev/null 2>&1 || { echo "vj-control: missing dependency '$1'" >&2; exit 127; } +} +need jq + +# Pick a transport that can speak to a UNIX stream socket. +if command -v socat >/dev/null 2>&1; then + send() { printf '%s\n' "$1" | socat -t2 - "UNIX-CONNECT:$SOCK" 2>/dev/null; } +elif command -v ncat >/dev/null 2>&1; then + send() { printf '%s\n' "$1" | ncat -U -w2 "$SOCK" 2>/dev/null; } +elif command -v nc >/dev/null 2>&1; then + send() { printf '%s\n' "$1" | nc -U -q2 "$SOCK" 2>/dev/null; } +else + echo "vj-control: need one of socat, ncat or nc (with -U support)" >&2 + exit 127 +fi + +# --- Rendering ---------------------------------------------------------------- +# Emit the three button lines and exit: / / . +render() { + printf '%s\n%s\n%s\n' "$1" "$2" "$3" + exit 0 +} + +usage() { + cat >&2 <<'EOF' +Usage: vj-control {next|last|random|shuffle|lock} +EOF + exit 2 +} + +# --- Dispatch ----------------------------------------------------------------- +cmd="${1:-}" +case "$cmd" in + next) label="NXT"; request='{"command":"next"}' ;; + last) label="PRV"; request='{"command":"last"}' ;; + random) label="RND"; request='{"command":"random"}' ;; + shuffle) label="SHF"; request='{"command":"shuffle"}' ;; + lock) label="LCK"; request='{"command":"lock"}' ;; + -h|--help|help|"") usage ;; + *) echo "vj-control: unknown function '$cmd'" >&2; usage ;; +esac + +reply="$(send "$request")" + +# No reply at all → projectMSDL is not running / socket unreachable. +if [[ -z "$reply" ]]; then + render "$ICON_OFFLINE" "$label" "$ICON_ERR" +fi + +status="$(printf '%s' "$reply" | jq -r '.status // "error"' 2>/dev/null)" + +# Choose the operation icon (line 1). For shuffle/lock it reflects the new state. +case "$cmd" in + next) icon="$ICON_NEXT" ;; + last) icon="$ICON_LAST" ;; + random) icon="$ICON_RANDOM" ;; + shuffle) + if [[ "$(printf '%s' "$reply" | jq -r '.shuffle' 2>/dev/null)" == "true" ]]; then + icon="$ICON_SHUFFLE_ON" + else + icon="$ICON_SHUFFLE_OFF" + fi + ;; + lock) + if [[ "$(printf '%s' "$reply" | jq -r '.locked' 2>/dev/null)" == "true" ]]; then + icon="$ICON_LOCK_ON" + else + icon="$ICON_LOCK_OFF" + fi + ;; +esac + +# Status icon (line 3). +if [[ "$status" == "ok" ]]; then + render "$icon" "$label" "$ICON_OK" +else + render "$icon" "$label" "$ICON_ERR" +fi diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 2e814e7..8c60b8a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -80,6 +80,22 @@ target_compile_definitions(projectMSDL PROJECTMSDL_VERSION="${PROJECT_VERSION}" ) +if (ENABLE_CONTROL_SOCKET) + target_sources(projectMSDL + PRIVATE + ControlSocket.cpp + ControlSocket.h + ) + target_compile_definitions(projectMSDL + PRIVATE + ENABLE_CONTROL_SOCKET + ) + target_link_libraries(projectMSDL + PRIVATE + Poco::JSON + ) +endif () + target_link_libraries(projectMSDL PRIVATE ProjectMSDL-GUI diff --git a/src/ControlSocket.cpp b/src/ControlSocket.cpp new file mode 100644 index 0000000..ef1ef8d --- /dev/null +++ b/src/ControlSocket.cpp @@ -0,0 +1,601 @@ +#include "ControlSocket.h" + +#include "ProjectMSDLApplication.h" +#include "ProjectMWrapper.h" + +#include "notifications/PlaybackControlNotification.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#ifndef _WIN32 +#include +#include +#include +#include +#endif + +/* + * Control socket protocol + * ======================= + * + * Clients connect to the UNIX domain socket and exchange newline-delimited JSON. Each request is a + * single JSON object on its own line; the server replies with a single JSON object line per request. + * Multiple commands may be sent over one connection. + * + * Requests (the "command" field is required): + * + * {"command": "next"} Advance to the next preset. + * {"command": "prev"} Go to the previous preset. ("previous" is also accepted.) + * {"command": "last"} Return to the previously displayed preset. + * {"command": "random"} Jump to a random preset. + * {"command": "shuffle"} Toggle shuffle mode. + * {"command": "shuffle", "enabled": true} Set shuffle mode explicitly. + * {"command": "lock"} Toggle the preset lock. + * {"command": "lock", "enabled": true} Set the preset lock explicitly. + * {"command": "select", "index": 5} Jump to the preset at the given playlist index. + * {"command": "select", "name": "Foo"} Jump to the first preset whose full path or file name matches. + * {"command": "status"} Query the current state without changing anything. + * + * The "next", "prev", "last", "random" and "select" commands accept an optional boolean + * "smooth" field (default false) requesting a soft transition instead of a hard cut. + * + * Responses always carry a "status" field, either "ok" or "error": + * + * {"status": "ok", "index": 5, "count": 1234, "preset": "/path/Foo.milk", "shuffle": true, "locked": false} + * {"status": "error", "error": "unknown command: foo"} + * + * Successful responses include the resulting playback state. Example client usage: + * + * echo '{"command":"next"}' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/projectMSDL.sock + */ + +namespace +{ +std::string SerializeResponse(Poco::JSON::Object& object) +{ + std::ostringstream stream; + object.stringify(stream); + stream << '\n'; + return stream.str(); +} + +std::string ErrorResponse(const std::string& message) +{ + Poco::JSON::Object response; + response.set("status", "error"); + response.set("error", message); + return SerializeResponse(response); +} +} // namespace + +const char* ControlSocket::name() const +{ + return "Control Socket"; +} + +#ifndef _WIN32 + +void ControlSocket::initialize(Poco::Util::Application& app) +{ + if (!app.config().getBool("controlSocket.enabled", false)) + { + poco_information(_logger, "Control socket is disabled by configuration."); + return; + } + + // Default to a per-user socket below the XDG runtime directory, falling back to /tmp. + std::string defaultDir = Poco::Environment::get("XDG_RUNTIME_DIR", "/tmp"); + Poco::Path defaultPath(defaultDir); + defaultPath.makeDirectory().setFileName("projectMSDL.sock"); + _socketPath = app.config().getString("controlSocket.path", defaultPath.toString()); + + if (_socketPath.size() >= sizeof(sockaddr_un::sun_path)) + { + poco_error_f1(_logger, "Control socket path is too long: %s", _socketPath); + return; + } + + if (::pipe(_shutdownPipe) != 0) + { + poco_error(_logger, "Failed to create control socket shutdown pipe."); + return; + } + + _listenFd = ::socket(AF_UNIX, SOCK_STREAM, 0); + if (_listenFd < 0) + { + poco_error(_logger, "Failed to create control socket."); + return; + } + + // Remove a stale socket file left behind by a previous, unclean shutdown. + ::unlink(_socketPath.c_str()); + + sockaddr_un address{}; + address.sun_family = AF_UNIX; + std::strncpy(address.sun_path, _socketPath.c_str(), sizeof(address.sun_path) - 1); + + if (::bind(_listenFd, reinterpret_cast(&address), sizeof(address)) != 0) + { + poco_error_f1(_logger, "Failed to bind control socket to %s.", _socketPath); + ::close(_listenFd); + _listenFd = -1; + return; + } + + if (::listen(_listenFd, 4) != 0) + { + poco_error(_logger, "Failed to listen on control socket."); + ::close(_listenFd); + _listenFd = -1; + ::unlink(_socketPath.c_str()); + return; + } + + _running = true; + _enabled = true; + _acceptThread = std::thread(&ControlSocket::AcceptLoop, this); + + poco_information_f1(_logger, "Control socket listening on %s.", _socketPath); +} + +void ControlSocket::uninitialize() +{ + if (!_enabled) + { + return; + } + + _running = false; + + // Wake the accept thread out of poll(). + if (_shutdownPipe[1] != -1) + { + const char wake = 'x'; + ssize_t ignored = ::write(_shutdownPipe[1], &wake, 1); + static_cast(ignored); + } + + // Fulfill any commands still queued so client threads blocked on their futures unblock. + { + std::lock_guard lock(_queueMutex); + while (!_commandQueue.empty()) + { + auto command = _commandQueue.front(); + _commandQueue.pop(); + try + { + command->response.set_value(ErrorResponse("shutting down")); + } + catch (...) + { + } + } + } + + // Interrupt any blocking recv() in connected client threads. + { + std::lock_guard lock(_clientsMutex); + for (int fd : _clientFds) + { + ::shutdown(fd, SHUT_RDWR); + } + } + + if (_acceptThread.joinable()) + { + _acceptThread.join(); + } + + // Detached client threads only access this object's members, which outlive them. + // Wait (bounded) for them to finish before releasing the socket resources. + for (int attempt = 0; _activeClients > 0 && attempt < 500; ++attempt) + { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + if (_listenFd != -1) + { + ::close(_listenFd); + _listenFd = -1; + } + if (_shutdownPipe[0] != -1) + { + ::close(_shutdownPipe[0]); + _shutdownPipe[0] = -1; + } + if (_shutdownPipe[1] != -1) + { + ::close(_shutdownPipe[1]); + _shutdownPipe[1] = -1; + } + + ::unlink(_socketPath.c_str()); + + poco_information(_logger, "Control socket stopped."); +} + +void ControlSocket::AcceptLoop() +{ + while (_running) + { + pollfd fds[2]; + fds[0].fd = _listenFd; + fds[0].events = POLLIN; + fds[0].revents = 0; + fds[1].fd = _shutdownPipe[0]; + fds[1].events = POLLIN; + fds[1].revents = 0; + + int result = ::poll(fds, 2, -1); + if (result < 0) + { + if (errno == EINTR) + { + continue; + } + poco_error(_logger, "Control socket poll() failed, stopping accept loop."); + break; + } + + if (fds[1].revents & POLLIN) + { + // Shutdown requested. + break; + } + + if (!(fds[0].revents & POLLIN)) + { + continue; + } + + int clientFd = ::accept(_listenFd, nullptr, nullptr); + if (clientFd < 0) + { + continue; + } + + { + std::lock_guard lock(_clientsMutex); + if (!_running) + { + ::close(clientFd); + break; + } + _clientFds.push_back(clientFd); + } + + std::thread(&ControlSocket::HandleClient, this, clientFd).detach(); + } +} + +void ControlSocket::HandleClient(int clientFd) +{ + ++_activeClients; + + std::string buffer; + char chunk[1024]; + + while (_running) + { + ssize_t received = ::recv(clientFd, chunk, sizeof(chunk), 0); + if (received <= 0) + { + break; + } + + buffer.append(chunk, static_cast(received)); + + // Process all complete, newline-delimited lines currently in the buffer. + std::string::size_type newline; + while ((newline = buffer.find('\n')) != std::string::npos) + { + std::string line = buffer.substr(0, newline); + buffer.erase(0, newline + 1); + + if (!line.empty() && line.back() == '\r') + { + line.pop_back(); + } + + // Trim leading/trailing whitespace; skip empty lines. + const auto first = line.find_first_not_of(" \t"); + if (first == std::string::npos) + { + continue; + } + const auto last = line.find_last_not_of(" \t"); + line = line.substr(first, last - first + 1); + + std::string responseLine; + try + { + Poco::JSON::Parser parser; + auto parsed = parser.parse(line); + auto request = parsed.extract(); + responseLine = DispatchToRenderThread(request); + } + catch (const Poco::Exception& ex) + { + responseLine = ErrorResponse("invalid JSON request: " + ex.displayText()); + } + catch (const std::exception& ex) + { + responseLine = ErrorResponse(std::string("invalid request: ") + ex.what()); + } + + // Write the full response, tolerating partial writes; bail if the client is gone. + size_t written = 0; + bool clientGone = false; + while (written < responseLine.size()) + { + ssize_t count = ::send(clientFd, responseLine.data() + written, + responseLine.size() - written, MSG_NOSIGNAL); + if (count <= 0) + { + clientGone = true; + break; + } + written += static_cast(count); + } + + if (clientGone) + { + break; + } + } + } + + { + std::lock_guard lock(_clientsMutex); + _clientFds.erase(std::remove(_clientFds.begin(), _clientFds.end(), clientFd), _clientFds.end()); + } + ::close(clientFd); + + --_activeClients; +} + +std::string ControlSocket::DispatchToRenderThread(const Poco::JSON::Object::Ptr& request) +{ + auto command = std::make_shared(); + command->request = request; + + std::future future; + { + std::lock_guard lock(_queueMutex); + if (!_running) + { + return ErrorResponse("shutting down"); + } + future = command->response.get_future(); + _commandQueue.push(command); + } + + // Block until the render thread executes the command. The wait is interruptible so a + // shutdown (which drains the queue) cannot leave this thread stuck forever. + while (true) + { + if (future.wait_for(std::chrono::milliseconds(100)) == std::future_status::ready) + { + return future.get(); + } + if (!_running) + { + if (future.wait_for(std::chrono::milliseconds(500)) == std::future_status::ready) + { + return future.get(); + } + return ErrorResponse("shutting down"); + } + } +} + +#else // _WIN32 + +void ControlSocket::initialize(Poco::Util::Application& app) +{ + static_cast(app); + poco_information(_logger, "Control socket is not supported on this platform."); +} + +void ControlSocket::uninitialize() +{ +} + +void ControlSocket::AcceptLoop() +{ +} + +void ControlSocket::HandleClient(int clientFd) +{ + static_cast(clientFd); +} + +std::string ControlSocket::DispatchToRenderThread(const Poco::JSON::Object::Ptr& request) +{ + static_cast(request); + return ErrorResponse("control socket not supported on this platform"); +} + +#endif // _WIN32 + +void ControlSocket::ProcessPendingCommands() +{ + if (!_enabled) + { + return; + } + + // Move the queued commands out under the lock, then execute without holding it. + std::queue> pending; + { + std::lock_guard lock(_queueMutex); + std::swap(pending, _commandQueue); + } + + while (!pending.empty()) + { + auto command = pending.front(); + pending.pop(); + try + { + command->response.set_value(ExecuteCommand(command->request)); + } + catch (...) + { + try + { + command->response.set_value(ErrorResponse("internal error")); + } + catch (...) + { + } + } + } +} + +void ControlSocket::AppendState(Poco::JSON::Object& response) +{ + auto& projectMWrapper = Poco::Util::Application::instance().getSubsystem(); + auto playlist = projectMWrapper.Playlist(); + auto projectM = projectMWrapper.ProjectM(); + + uint32_t count = projectm_playlist_size(playlist); + uint32_t index = projectm_playlist_get_position(playlist); + + response.set("index", index); + response.set("count", count); + response.set("shuffle", static_cast(projectm_playlist_get_shuffle(playlist))); + response.set("locked", static_cast(projectm_get_preset_locked(projectM))); + + if (count > 0) + { + auto* presetName = projectm_playlist_item(playlist, index); + if (presetName != nullptr) + { + response.set("preset", std::string(presetName)); + projectm_playlist_free_string(presetName); + } + } +} + +std::string ControlSocket::ExecuteCommand(const Poco::JSON::Object::Ptr& request) +{ + if (request.isNull() || !request->has("command")) + { + return ErrorResponse("missing 'command' field"); + } + + const std::string command = request->getValue("command"); + const bool smooth = request->optValue("smooth", false); + + auto& notificationCenter = Poco::NotificationCenter::defaultCenter(); + auto& projectMWrapper = Poco::Util::Application::instance().getSubsystem(); + auto playlist = projectMWrapper.Playlist(); + auto projectM = projectMWrapper.ProjectM(); + auto userConfig = ProjectMSDLApplication::instance().UserConfiguration(); + + if (command == "next") + { + notificationCenter.postNotification( + new PlaybackControlNotification(PlaybackControlNotification::Action::NextPreset, smooth)); + } + else if (command == "prev" || command == "previous") + { + notificationCenter.postNotification( + new PlaybackControlNotification(PlaybackControlNotification::Action::PreviousPreset, smooth)); + } + else if (command == "last") + { + notificationCenter.postNotification( + new PlaybackControlNotification(PlaybackControlNotification::Action::LastPreset, smooth)); + } + else if (command == "random") + { + notificationCenter.postNotification( + new PlaybackControlNotification(PlaybackControlNotification::Action::RandomPreset, smooth)); + } + else if (command == "shuffle") + { + bool target = request->has("enabled") + ? request->getValue("enabled") + : !projectm_playlist_get_shuffle(playlist); + // Route through the user configuration so the UI and persisted settings stay in sync. + userConfig->setBool("projectM.shuffleEnabled", target); + } + else if (command == "lock") + { + bool target = request->has("enabled") + ? request->getValue("enabled") + : !projectm_get_preset_locked(projectM); + userConfig->setBool("projectM.presetLocked", target); + } + else if (command == "select") + { + uint32_t count = projectm_playlist_size(playlist); + + if (request->has("index")) + { + int index = request->getValue("index"); + if (index < 0 || static_cast(index) >= count) + { + return ErrorResponse("index out of range"); + } + projectm_playlist_set_position(playlist, static_cast(index), !smooth); + } + else if (request->has("name")) + { + const std::string name = request->getValue("name"); + bool found = false; + for (uint32_t i = 0; i < count; ++i) + { + auto* item = projectm_playlist_item(playlist, i); + if (item != nullptr) + { + std::string itemPath(item); + projectm_playlist_free_string(item); + + // Match either the full path or the trailing file name component. + auto slash = itemPath.find_last_of("/\\"); + std::string fileName = slash == std::string::npos ? itemPath : itemPath.substr(slash + 1); + if (itemPath == name || fileName == name) + { + projectm_playlist_set_position(playlist, i, !smooth); + found = true; + break; + } + } + } + if (!found) + { + return ErrorResponse("no preset matching name: " + name); + } + } + else + { + return ErrorResponse("'select' requires an 'index' or 'name' field"); + } + } + else if (command == "status") + { + // No state change; the response below reports the current state. + } + else + { + return ErrorResponse("unknown command: " + command); + } + + Poco::JSON::Object response; + response.set("status", "ok"); + AppendState(response); + return SerializeResponse(response); +} diff --git a/src/ControlSocket.h b/src/ControlSocket.h new file mode 100644 index 0000000..cd1deb9 --- /dev/null +++ b/src/ControlSocket.h @@ -0,0 +1,107 @@ +#pragma once + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * @brief Exposes application control over a UNIX domain socket. + * + * On initialization, this subsystem creates a UNIX domain socket (path is configurable via + * the "controlSocket.path" setting) and listens for client connections on a background thread. + * + * Clients send newline-delimited JSON request objects and receive a newline-delimited JSON + * response object for each. The protocol is documented in ControlSocket.cpp. + * + * Because libprojectM and its playlist are not thread-safe with respect to the render thread, + * incoming commands are never executed on the socket thread. Instead they are queued and + * executed by the render thread via ProcessPendingCommands(), which is called once per frame. + * The socket thread blocks on a future until the render thread has produced the response. + */ +class ControlSocket : public Poco::Util::Subsystem +{ +public: + const char* name() const override; + + void initialize(Poco::Util::Application& app) override; + + void uninitialize() override; + + /** + * @brief Executes all queued socket commands on the calling (render) thread. + * + * Must be called regularly from the render loop. Each executed command fulfills the + * promise the originating socket thread is waiting on. Safe to call even if the socket + * is disabled or unsupported on the current platform (it is then a no-op). + */ + void ProcessPendingCommands(); + +private: + /** + * @brief A single queued request/response pair handed from a socket thread to the render thread. + */ + struct Command + { + Poco::JSON::Object::Ptr request; //!< Parsed JSON request object. + std::promise response; //!< Fulfilled by the render thread with the serialized response line. + }; + + /** + * @brief Background thread accepting incoming client connections. + */ + void AcceptLoop(); + + /** + * @brief Handles a single connected client until it disconnects or shutdown is requested. + * @param clientFd The accepted client socket file descriptor. + */ + void HandleClient(int clientFd); + + /** + * @brief Enqueues a request for the render thread and blocks until the response is ready. + * @param request The parsed JSON request. + * @return The serialized JSON response line (including trailing newline). + */ + std::string DispatchToRenderThread(const Poco::JSON::Object::Ptr& request); + + /** + * @brief Executes a single command on the render thread and builds the JSON response line. + * @param request The parsed JSON request. + * @return The serialized JSON response line (including trailing newline). + */ + std::string ExecuteCommand(const Poco::JSON::Object::Ptr& request); + + /** + * @brief Adds the current playback state (preset index/name, shuffle, lock, count) to a response. + * @param response The JSON object to populate. + */ + void AppendState(Poco::JSON::Object& response); + + std::string _socketPath; //!< Filesystem path of the UNIX domain socket. + bool _enabled{false}; //!< True if the socket was successfully created and is being served. + + int _listenFd{-1}; //!< Listening socket file descriptor. + int _shutdownPipe[2]{-1, -1}; //!< Self-pipe used to wake the accept thread on shutdown. + + std::atomic _running{false}; //!< Cleared on shutdown to stop accepting/processing. + + std::thread _acceptThread; //!< Thread running AcceptLoop(). + std::mutex _clientsMutex; //!< Guards _clientFds. + std::vector _clientFds; //!< Currently connected client file descriptors. + std::atomic _activeClients{0}; //!< Number of running (detached) client handler threads. + + std::mutex _queueMutex; //!< Guards _commandQueue and gates enqueueing against shutdown. + std::queue> _commandQueue; //!< Commands awaiting execution on the render thread. + + Poco::Logger& _logger{Poco::Logger::get("ControlSocket")}; //!< The class logger. +}; diff --git a/src/ProjectMSDLApplication.cpp b/src/ProjectMSDLApplication.cpp index 3d12e58..47e41fd 100644 --- a/src/ProjectMSDLApplication.cpp +++ b/src/ProjectMSDLApplication.cpp @@ -5,6 +5,9 @@ #include "ProjectMSDLApplication.h" #include "AudioCapture.h" +#ifdef ENABLE_CONTROL_SOCKET +#include "ControlSocket.h" +#endif #include "ProjectMWrapper.h" #include "RenderLoop.h" #include "SDLRenderingWindow.h" @@ -26,6 +29,9 @@ ProjectMSDLApplication::ProjectMSDLApplication() addSubsystem(new ProjectMWrapper); addSubsystem(new AudioCapture); addSubsystem(new ProjectMGUI); +#ifdef ENABLE_CONTROL_SOCKET + addSubsystem(new ControlSocket); +#endif } const char* ProjectMSDLApplication::name() const @@ -233,6 +239,16 @@ void ProjectMSDLApplication::defineOptions(Poco::Util::OptionSet& options) options.addOption(Option("beatSensitivity", "", "Beat sensitivity. Between 0.0 and 2.0. Default 1.0.", false, "", true) .binding("projectM.beatSensitivity", _commandLineOverrides)); + +#ifdef ENABLE_CONTROL_SOCKET + options.addOption(Option("controlSocket", "", "Enable the control socket for external application control. Disabled by default. UNIX platforms only.", + false, "<0/1>", true) + .binding("controlSocket.enabled", _commandLineOverrides)); + + options.addOption(Option("controlSocketPath", "", "Path of the control socket. Defaults to $XDG_RUNTIME_DIR/projectMSDL.sock.", + false, "", true) + .binding("controlSocket.path", _commandLineOverrides)); +#endif } int ProjectMSDLApplication::main(POCO_UNUSED const std::vector& args) diff --git a/src/RenderLoop.cpp b/src/RenderLoop.cpp index 7d428e0..e2255bc 100644 --- a/src/RenderLoop.cpp +++ b/src/RenderLoop.cpp @@ -16,6 +16,9 @@ RenderLoop::RenderLoop() : _audioCapture(Poco::Util::Application::instance().getSubsystem()) , _projectMWrapper(Poco::Util::Application::instance().getSubsystem()) , _sdlRenderingWindow(Poco::Util::Application::instance().getSubsystem()) +#ifdef ENABLE_CONTROL_SOCKET + , _controlSocket(Poco::Util::Application::instance().getSubsystem()) +#endif , _projectMHandle(_projectMWrapper.ProjectM()) , _playlistHandle(_projectMWrapper.Playlist()) , _projectMGui(Poco::Util::Application::instance().getSubsystem()) @@ -39,6 +42,9 @@ void RenderLoop::Run() limiter.StartFrame(); PollEvents(); +#ifdef ENABLE_CONTROL_SOCKET + _controlSocket.ProcessPendingCommands(); +#endif CheckViewportSize(); _audioCapture.FillBuffer(); _projectMWrapper.RenderFrame(); diff --git a/src/RenderLoop.h b/src/RenderLoop.h index 9429b1f..0a05263 100644 --- a/src/RenderLoop.h +++ b/src/RenderLoop.h @@ -1,6 +1,9 @@ #pragma once #include "AudioCapture.h" +#ifdef ENABLE_CONTROL_SOCKET +#include "ControlSocket.h" +#endif #include "ProjectMWrapper.h" #include "SDLRenderingWindow.h" @@ -75,6 +78,9 @@ class RenderLoop AudioCapture& _audioCapture; ProjectMWrapper& _projectMWrapper; SDLRenderingWindow& _sdlRenderingWindow; +#ifdef ENABLE_CONTROL_SOCKET + ControlSocket& _controlSocket; +#endif projectm_handle _projectMHandle{nullptr}; projectm_playlist_handle _playlistHandle{nullptr}; diff --git a/src/resources/projectMSDL.properties.in b/src/resources/projectMSDL.properties.in index a3d5889..0ec9f67 100644 --- a/src/resources/projectMSDL.properties.in +++ b/src/resources/projectMSDL.properties.in @@ -101,6 +101,19 @@ projectM.hardCutDuration = 20 projectM.aspectCorrectionEnabled = true +### Control socket settings + +# If enabled, projectMSDL creates a UNIX domain socket on startup that can be used to control +# the application (preset navigation, shuffle/lock toggles, preset selection and state queries) +# from external tools. Clients exchange newline-delimited JSON messages. +# Only available on UNIX platforms. Disabled by default; can also be toggled with the --controlSocket command-line option. +controlSocket.enabled = false + +# Path of the UNIX domain socket. If left unset, it defaults to $XDG_RUNTIME_DIR/projectMSDL.sock +# (falling back to /tmp/projectMSDL.sock if XDG_RUNTIME_DIR is not defined). +#controlSocket.path = /run/user/1000/projectMSDL.sock + + ### Logging settings # For detailed information on how to configure logging, please refer to the POCO documentation: