From b8171bad3e53bad2e6e5ddb20cf44e487a60ae4e Mon Sep 17 00:00:00 2001 From: Radek blufor Slavicinsky Date: Fri, 12 Jun 2026 01:06:53 +0200 Subject: [PATCH] Provide a UNIX domain control socket ...to control the application by other means (ie. StreamDecks and such). This integrates creating `AF_UNIX`/`SOCK_STREAM` listen socket with a very simple JSON API (using `Poco/JSON`) that allows one to do thing such as: - switch presets - toggle lock and shuffle - get status from outside of the application. > Wayland by design doesn't allow one to send keystrokes to non-active windows. It's actually against the design. Every API response reports on the status as well. The simplicity allows one to integrate practically anything with just `bash`, `socat` and `jq`. In the `examples/vj-control`, one can find a tool that parses the output into stylish format that can be used on StreamDecks. One thing I'm not very sure about is how it's hooked into the RenderLoop, however it seems to me there's no other (simple) way around it to properly report back with the status. It is definitely a compromise. > NOTE: I'm a senior devops engineer, however C++ is not one of the languages I'm friends with (on the contrary actually), so - guilty as charged - I've used Claude Code to help me implement it. The feature is guarded behind `-DENABLE_CONTROL_SOCKET=ON` --- CMakeLists.txt | 2 + examples/vj-control | 132 ++++++ src/CMakeLists.txt | 16 + src/ControlSocket.cpp | 601 ++++++++++++++++++++++++ src/ControlSocket.h | 107 +++++ src/ProjectMSDLApplication.cpp | 16 + src/RenderLoop.cpp | 6 + src/RenderLoop.h | 6 + src/resources/projectMSDL.properties.in | 13 + 9 files changed, 899 insertions(+) create mode 100755 examples/vj-control create mode 100644 src/ControlSocket.cpp create mode 100644 src/ControlSocket.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 3ee70505..9e1cd1dd 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 00000000..96f961f2 --- /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 2e814e75..8c60b8a3 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 00000000..ef1ef8da --- /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 00000000..cd1deb9c --- /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 3d12e582..47e41fd6 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 7d428e0f..e2255bc3 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 9429b1ff..0a052636 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 a3d5889f..0ec9f67f 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: