Initial commit: pwweb - PipeWire WebUI
C++ backend with SSE streaming (reuses qpwgraph PipeWire callbacks). Svelte frontend with custom SVG canvas for port-level connections. Features: - Live PipeWire graph via SSE - Drag output->input port to connect - Double-click or select+Delete to disconnect - Node positions saved to localStorage - Pan (drag bg) and zoom (scroll) - Port type coloring (audio=green, midi=red, video=blue)
This commit is contained in:
773
src/graph_engine.cpp
Normal file
773
src/graph_engine.cpp
Normal file
@@ -0,0 +1,773 @@
|
||||
#include "graph_engine.h"
|
||||
|
||||
#include <pipewire/pipewire.h>
|
||||
#include <spa/utils/result.h>
|
||||
#include <spa/utils/list.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
|
||||
// Default port type strings (must match qpwgraph)
|
||||
#define DEFAULT_AUDIO_TYPE "32 bit float mono audio"
|
||||
#define DEFAULT_MIDI_TYPE "8 bit raw midi"
|
||||
#define DEFAULT_MIDI2_TYPE "32 bit raw UMP"
|
||||
#define DEFAULT_VIDEO_TYPE "32 bit float RGBA video"
|
||||
#define DEFAULT_OTHER_TYPE "PIPEWIRE_PORT_TYPE"
|
||||
|
||||
using namespace pwgraph;
|
||||
|
||||
// ============================================================================
|
||||
// Pending/sync helpers (ported from qpwgraph_pipewire.cpp)
|
||||
// ============================================================================
|
||||
|
||||
static void add_pending(GraphEngine::Object *obj, GraphEngine::PwData &pw) {
|
||||
if (obj->pending_seq == 0)
|
||||
spa_list_append(&pw.pending, &obj->pending_link);
|
||||
obj->pending_seq = pw_core_sync(pw.core, 0, obj->pending_seq);
|
||||
pw.pending_seq = obj->pending_seq;
|
||||
}
|
||||
|
||||
static void remove_pending(GraphEngine::Object *obj) {
|
||||
if (obj->pending_seq != 0) {
|
||||
spa_list_remove(&obj->pending_link);
|
||||
obj->pending_seq = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Proxy helpers
|
||||
// ============================================================================
|
||||
|
||||
static void destroy_proxy(GraphEngine::Object *obj) {
|
||||
if (obj->proxy) {
|
||||
pw_proxy_destroy((pw_proxy*)obj->proxy);
|
||||
obj->proxy = nullptr;
|
||||
}
|
||||
spa_hook_remove(&obj->object_listener);
|
||||
spa_hook_remove(&obj->proxy_listener);
|
||||
remove_pending(obj);
|
||||
if (obj->info && obj->destroy_info) {
|
||||
obj->destroy_info(obj->info);
|
||||
obj->info = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Node events (ported from qpwgraph_pipewire.cpp lines 224-278)
|
||||
// ============================================================================
|
||||
|
||||
static void on_node_info(void *data, const struct pw_node_info *info) {
|
||||
auto *obj = static_cast<GraphEngine::Object*>(data);
|
||||
if (!obj) return;
|
||||
|
||||
info = pw_node_info_update((struct pw_node_info*)obj->info, info);
|
||||
obj->info = (void*)info;
|
||||
|
||||
if (info && (info->change_mask & PW_NODE_CHANGE_MASK_PROPS)) {
|
||||
auto *nobj = static_cast<GraphEngine::NodeObj*>(obj);
|
||||
if (info->props) {
|
||||
const char *media_name = spa_dict_lookup(info->props, PW_KEY_MEDIA_NAME);
|
||||
if (media_name && strlen(media_name) > 0)
|
||||
nobj->node.media_name = media_name;
|
||||
}
|
||||
nobj->node.changed = true;
|
||||
nobj->node.ready = true;
|
||||
}
|
||||
}
|
||||
|
||||
static const struct pw_node_events node_events = {
|
||||
.version = PW_VERSION_NODE_EVENTS,
|
||||
.info = on_node_info,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Port events
|
||||
// ============================================================================
|
||||
|
||||
static void on_port_info(void *data, const struct pw_port_info *info) {
|
||||
auto *obj = static_cast<GraphEngine::Object*>(data);
|
||||
if (!obj) return;
|
||||
info = pw_port_info_update((struct pw_port_info*)obj->info, info);
|
||||
obj->info = (void*)info;
|
||||
}
|
||||
|
||||
static const struct pw_port_events port_events = {
|
||||
.version = PW_VERSION_PORT_EVENTS,
|
||||
.info = on_port_info,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Link events
|
||||
// ============================================================================
|
||||
|
||||
static void on_link_info(void *data, const struct pw_link_info *info) {
|
||||
auto *obj = static_cast<GraphEngine::Object*>(data);
|
||||
if (!obj) return;
|
||||
info = pw_link_info_update((struct pw_link_info*)obj->info, info);
|
||||
obj->info = (void*)info;
|
||||
}
|
||||
|
||||
static const struct pw_link_events link_events = {
|
||||
.version = PW_VERSION_LINK_EVENTS,
|
||||
.info = on_link_info,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Proxy events
|
||||
// ============================================================================
|
||||
|
||||
static void on_proxy_removed(void *data) {
|
||||
auto *obj = static_cast<GraphEngine::Object*>(data);
|
||||
if (obj && obj->proxy) {
|
||||
pw_proxy *proxy = (pw_proxy*)obj->proxy;
|
||||
obj->proxy = nullptr;
|
||||
pw_proxy_destroy(proxy);
|
||||
}
|
||||
}
|
||||
|
||||
static void on_proxy_destroy(void *data) {
|
||||
auto *obj = static_cast<GraphEngine::Object*>(data);
|
||||
if (obj)
|
||||
destroy_proxy(obj);
|
||||
}
|
||||
|
||||
static const struct pw_proxy_events proxy_events = {
|
||||
.version = PW_VERSION_PROXY_EVENTS,
|
||||
.destroy = on_proxy_destroy,
|
||||
.removed = on_proxy_removed,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Object proxy creation (adapted from qpwgraph_pipewire::Object::create_proxy)
|
||||
// ============================================================================
|
||||
|
||||
static void create_proxy_for_object(GraphEngine::Object *obj, GraphEngine *engine) {
|
||||
if (obj->proxy) return;
|
||||
|
||||
const char *proxy_type = nullptr;
|
||||
uint32_t version = 0;
|
||||
void (*destroy_info)(void*) = nullptr;
|
||||
const void *events = nullptr;
|
||||
|
||||
switch (obj->type) {
|
||||
case GraphEngine::Object::ObjNode:
|
||||
proxy_type = PW_TYPE_INTERFACE_Node;
|
||||
version = PW_VERSION_NODE;
|
||||
destroy_info = (void(*)(void*))pw_node_info_free;
|
||||
events = &node_events;
|
||||
break;
|
||||
case GraphEngine::Object::ObjPort:
|
||||
proxy_type = PW_TYPE_INTERFACE_Port;
|
||||
version = PW_VERSION_PORT;
|
||||
destroy_info = (void(*)(void*))pw_port_info_free;
|
||||
events = &port_events;
|
||||
break;
|
||||
case GraphEngine::Object::ObjLink:
|
||||
proxy_type = PW_TYPE_INTERFACE_Link;
|
||||
version = PW_VERSION_LINK;
|
||||
destroy_info = (void(*)(void*))pw_link_info_free;
|
||||
events = &link_events;
|
||||
break;
|
||||
}
|
||||
|
||||
auto &pw = engine->pwData();
|
||||
pw_proxy *proxy = (pw_proxy*)pw_registry_bind(
|
||||
pw.registry, obj->id, proxy_type, version, 0);
|
||||
if (proxy) {
|
||||
obj->proxy = proxy;
|
||||
obj->destroy_info = destroy_info;
|
||||
obj->pending_seq = 0;
|
||||
pw_proxy_add_object_listener(proxy,
|
||||
&obj->object_listener, events, obj);
|
||||
pw_proxy_add_listener(proxy,
|
||||
&obj->proxy_listener, &proxy_events, obj);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Core events (sync/error handling)
|
||||
// ============================================================================
|
||||
|
||||
static void on_core_done(void *data, uint32_t id, int seq) {
|
||||
auto *engine = static_cast<GraphEngine*>(data);
|
||||
auto &pw = engine->pwData();
|
||||
|
||||
if (id == PW_ID_CORE) {
|
||||
pw.last_seq = seq;
|
||||
if (pw.pending_seq == seq)
|
||||
pw_thread_loop_signal(pw.loop, false);
|
||||
}
|
||||
}
|
||||
|
||||
static void on_core_error(void *data, uint32_t id, int seq, int res, const char *message) {
|
||||
auto *engine = static_cast<GraphEngine*>(data);
|
||||
auto &pw = engine->pwData();
|
||||
|
||||
if (id == PW_ID_CORE) {
|
||||
pw.last_res = res;
|
||||
if (res == -EPIPE)
|
||||
pw.error = true;
|
||||
}
|
||||
pw_thread_loop_signal(pw.loop, false);
|
||||
}
|
||||
|
||||
static const struct pw_core_events core_events = {
|
||||
.version = PW_VERSION_CORE_EVENTS,
|
||||
.info = nullptr,
|
||||
.done = on_core_done,
|
||||
.error = on_core_error,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Link proxy sync (for connect/disconnect operations)
|
||||
// ============================================================================
|
||||
|
||||
static int link_proxy_sync(GraphEngine *engine) {
|
||||
auto &pw = engine->pwData();
|
||||
|
||||
if (pw_thread_loop_in_thread(pw.loop))
|
||||
return 0;
|
||||
|
||||
pw.pending_seq = pw_proxy_sync((pw_proxy*)pw.core, pw.pending_seq);
|
||||
|
||||
while (true) {
|
||||
pw_thread_loop_wait(pw.loop);
|
||||
if (pw.error)
|
||||
return pw.last_res;
|
||||
if (pw.pending_seq == pw.last_seq)
|
||||
break;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void on_link_proxy_error(void *data, int seq, int res, const char *message) {
|
||||
int *link_res = (int*)data;
|
||||
*link_res = res;
|
||||
}
|
||||
|
||||
static const struct pw_proxy_events link_proxy_events = {
|
||||
.version = PW_VERSION_PROXY_EVENTS,
|
||||
.error = on_link_proxy_error,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Registry events (the main entry point — ported from qpwgraph lines 425-568)
|
||||
// ============================================================================
|
||||
|
||||
static void on_registry_global(void *data,
|
||||
uint32_t id, uint32_t permissions,
|
||||
const char *type, uint32_t version,
|
||||
const struct spa_dict *props)
|
||||
{
|
||||
if (!props) return;
|
||||
|
||||
auto *engine = static_cast<GraphEngine*>(data);
|
||||
|
||||
if (strcmp(type, PW_TYPE_INTERFACE_Node) == 0) {
|
||||
// Parse node properties (ported from qpwgraph lines 444-489)
|
||||
const char *str = spa_dict_lookup(props, PW_KEY_NODE_DESCRIPTION);
|
||||
const char *nick = spa_dict_lookup(props, PW_KEY_NODE_NICK);
|
||||
if (!str || strlen(str) < 1) str = nick;
|
||||
if (!str || strlen(str) < 1) str = nick = spa_dict_lookup(props, PW_KEY_NODE_NAME);
|
||||
if (!str || strlen(str) < 1) str = "node";
|
||||
|
||||
std::string node_name;
|
||||
const char *app = spa_dict_lookup(props, PW_KEY_APP_NAME);
|
||||
if (app && strlen(app) > 0 && strcmp(app, str) != 0) {
|
||||
node_name += app;
|
||||
node_name += '/';
|
||||
}
|
||||
node_name += str;
|
||||
std::string node_nick = (nick ? nick : str);
|
||||
|
||||
PortMode node_mode = PortMode::None;
|
||||
uint8_t node_types = 0;
|
||||
|
||||
str = spa_dict_lookup(props, PW_KEY_MEDIA_CLASS);
|
||||
if (str) {
|
||||
if (strstr(str, "Source") || strstr(str, "Output"))
|
||||
node_mode = PortMode::Output;
|
||||
else if (strstr(str, "Sink") || strstr(str, "Input"))
|
||||
node_mode = PortMode::Input;
|
||||
if (strstr(str, "Audio")) node_types |= (uint8_t)NodeType::Audio;
|
||||
if (strstr(str, "Video")) node_types |= (uint8_t)NodeType::Video;
|
||||
if (strstr(str, "Midi")) node_types |= (uint8_t)NodeType::Midi;
|
||||
}
|
||||
if (node_mode == PortMode::None) {
|
||||
str = spa_dict_lookup(props, PW_KEY_MEDIA_CATEGORY);
|
||||
if (str && strstr(str, "Duplex"))
|
||||
node_mode = PortMode::Duplex;
|
||||
}
|
||||
|
||||
// Create node object
|
||||
auto *nobj = new GraphEngine::NodeObj(id);
|
||||
nobj->node.id = id;
|
||||
nobj->node.name = node_name;
|
||||
nobj->node.nick = node_nick;
|
||||
nobj->node.mode = node_mode;
|
||||
nobj->node.node_type = NodeType(node_types);
|
||||
nobj->node.mode2 = node_mode;
|
||||
nobj->node.ready = false;
|
||||
nobj->node.changed = false;
|
||||
|
||||
engine->addObject(id, nobj);
|
||||
create_proxy_for_object(nobj, engine);
|
||||
engine->notifyChanged();
|
||||
}
|
||||
else if (strcmp(type, PW_TYPE_INTERFACE_Port) == 0) {
|
||||
// Parse port properties (ported from qpwgraph lines 492-535)
|
||||
const char *str = spa_dict_lookup(props, PW_KEY_NODE_ID);
|
||||
const uint32_t node_id = (str ? (uint32_t)atoi(str) : 0);
|
||||
|
||||
std::string port_name;
|
||||
str = spa_dict_lookup(props, PW_KEY_PORT_ALIAS);
|
||||
if (!str) str = spa_dict_lookup(props, PW_KEY_PORT_NAME);
|
||||
if (!str) str = "port";
|
||||
port_name = str;
|
||||
|
||||
auto *node_obj = engine->findNode(node_id);
|
||||
|
||||
uint32_t port_type = engine->otherPortType();
|
||||
str = spa_dict_lookup(props, PW_KEY_FORMAT_DSP);
|
||||
if (str) {
|
||||
port_type = hashType(str);
|
||||
} else if (node_obj && (node_obj->node.node_type & NodeType::Video) != NodeType::None) {
|
||||
port_type = engine->videoPortType();
|
||||
}
|
||||
|
||||
PortMode port_mode = PortMode::None;
|
||||
str = spa_dict_lookup(props, PW_KEY_PORT_DIRECTION);
|
||||
if (str) {
|
||||
if (strcmp(str, "in") == 0) port_mode = PortMode::Input;
|
||||
else if (strcmp(str, "out") == 0) port_mode = PortMode::Output;
|
||||
}
|
||||
|
||||
uint8_t port_flags = 0;
|
||||
if (node_obj && (node_obj->node.mode2 != PortMode::Duplex))
|
||||
port_flags |= (uint8_t)PortFlags::Terminal;
|
||||
str = spa_dict_lookup(props, PW_KEY_PORT_PHYSICAL);
|
||||
if (str && pw_properties_parse_bool(str))
|
||||
port_flags |= (uint8_t)PortFlags::Physical;
|
||||
str = spa_dict_lookup(props, PW_KEY_PORT_TERMINAL);
|
||||
if (str && pw_properties_parse_bool(str))
|
||||
port_flags |= (uint8_t)PortFlags::Terminal;
|
||||
str = spa_dict_lookup(props, PW_KEY_PORT_MONITOR);
|
||||
if (str && pw_properties_parse_bool(str))
|
||||
port_flags |= (uint8_t)PortFlags::Monitor;
|
||||
str = spa_dict_lookup(props, PW_KEY_PORT_CONTROL);
|
||||
if (str && pw_properties_parse_bool(str))
|
||||
port_flags |= (uint8_t)PortFlags::Control;
|
||||
|
||||
if (!node_obj) return; // Node not ready yet
|
||||
|
||||
auto *pobj = new GraphEngine::PortObj(id);
|
||||
pobj->port.id = id;
|
||||
pobj->port.node_id = node_id;
|
||||
pobj->port.name = port_name;
|
||||
pobj->port.mode = port_mode;
|
||||
pobj->port.port_type = port_type;
|
||||
pobj->port.flags = PortFlags(port_flags);
|
||||
|
||||
// Update node's mode2 if port direction differs
|
||||
if ((node_obj->node.mode2 & port_mode) == PortMode::None)
|
||||
node_obj->node.mode2 = PortMode::Duplex;
|
||||
|
||||
node_obj->node.port_ids.push_back(id);
|
||||
node_obj->node.changed = true;
|
||||
|
||||
engine->addObject(id, pobj);
|
||||
create_proxy_for_object(pobj, engine);
|
||||
engine->notifyChanged();
|
||||
}
|
||||
else if (strcmp(type, PW_TYPE_INTERFACE_Link) == 0) {
|
||||
// Parse link properties (ported from qpwgraph lines 538-545)
|
||||
const char *str = spa_dict_lookup(props, PW_KEY_LINK_OUTPUT_PORT);
|
||||
const uint32_t port1_id = (str ? (uint32_t)pw_properties_parse_int(str) : 0);
|
||||
str = spa_dict_lookup(props, PW_KEY_LINK_INPUT_PORT);
|
||||
const uint32_t port2_id = (str ? (uint32_t)pw_properties_parse_int(str) : 0);
|
||||
|
||||
// Validate
|
||||
auto *p1 = engine->findPort(port1_id);
|
||||
auto *p2 = engine->findPort(port2_id);
|
||||
if (!p1 || !p2) return;
|
||||
if ((p1->port.mode & PortMode::Output) == PortMode::None) return;
|
||||
if ((p2->port.mode & PortMode::Input) == PortMode::None) return;
|
||||
|
||||
auto *lobj = new GraphEngine::LinkObj(id);
|
||||
lobj->link.id = id;
|
||||
lobj->link.port1_id = port1_id;
|
||||
lobj->link.port2_id = port2_id;
|
||||
|
||||
p1->port.link_ids.push_back(id);
|
||||
|
||||
engine->addObject(id, lobj);
|
||||
create_proxy_for_object(lobj, engine);
|
||||
engine->notifyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
static void on_registry_global_remove(void *data, uint32_t id) {
|
||||
auto *engine = static_cast<GraphEngine*>(data);
|
||||
engine->removeObject(id);
|
||||
engine->notifyChanged();
|
||||
}
|
||||
|
||||
static const struct pw_registry_events registry_events = {
|
||||
.version = PW_VERSION_REGISTRY_EVENTS,
|
||||
.global = on_registry_global,
|
||||
.global_remove = on_registry_global_remove,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// GraphEngine::Object
|
||||
// ============================================================================
|
||||
|
||||
GraphEngine::Object::Object(uint32_t id, Type type)
|
||||
: id(id), type(type), proxy(nullptr), info(nullptr),
|
||||
destroy_info(nullptr), pending_seq(0)
|
||||
{
|
||||
spa_zero(proxy_listener);
|
||||
spa_zero(object_listener);
|
||||
spa_list_init(&pending_link);
|
||||
}
|
||||
|
||||
GraphEngine::Object::~Object() {
|
||||
if (proxy) {
|
||||
destroy_proxy(this);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GraphEngine
|
||||
// ============================================================================
|
||||
|
||||
GraphEngine::GraphEngine()
|
||||
: m_on_change(nullptr), m_running(false)
|
||||
{
|
||||
m_audio_type = hashType(DEFAULT_AUDIO_TYPE);
|
||||
m_midi_type = hashType(DEFAULT_MIDI_TYPE);
|
||||
m_midi2_type = hashType(DEFAULT_MIDI2_TYPE);
|
||||
m_video_type = hashType(DEFAULT_VIDEO_TYPE);
|
||||
m_other_type = hashType(DEFAULT_OTHER_TYPE);
|
||||
|
||||
memset(&m_pw, 0, sizeof(m_pw));
|
||||
spa_list_init(&m_pw.pending);
|
||||
}
|
||||
|
||||
GraphEngine::~GraphEngine() {
|
||||
close();
|
||||
}
|
||||
|
||||
bool GraphEngine::open() {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
|
||||
pw_init(nullptr, nullptr);
|
||||
|
||||
memset(&m_pw, 0, sizeof(m_pw));
|
||||
spa_list_init(&m_pw.pending);
|
||||
m_pw.pending_seq = 0;
|
||||
|
||||
m_pw.loop = pw_thread_loop_new("pwweb_loop", nullptr);
|
||||
if (!m_pw.loop) {
|
||||
fprintf(stderr, "pw_thread_loop_new failed\n");
|
||||
pw_deinit();
|
||||
return false;
|
||||
}
|
||||
|
||||
pw_thread_loop_lock(m_pw.loop);
|
||||
|
||||
struct pw_loop *loop = pw_thread_loop_get_loop(m_pw.loop);
|
||||
m_pw.context = pw_context_new(loop, nullptr, 0);
|
||||
if (!m_pw.context) {
|
||||
fprintf(stderr, "pw_context_new failed\n");
|
||||
pw_thread_loop_unlock(m_pw.loop);
|
||||
pw_thread_loop_destroy(m_pw.loop);
|
||||
pw_deinit();
|
||||
return false;
|
||||
}
|
||||
|
||||
m_pw.core = pw_context_connect(m_pw.context, nullptr, 0);
|
||||
if (!m_pw.core) {
|
||||
fprintf(stderr, "pw_context_connect failed\n");
|
||||
pw_thread_loop_unlock(m_pw.loop);
|
||||
pw_context_destroy(m_pw.context);
|
||||
pw_thread_loop_destroy(m_pw.loop);
|
||||
pw_deinit();
|
||||
return false;
|
||||
}
|
||||
|
||||
pw_core_add_listener(m_pw.core,
|
||||
&m_pw.core_listener, &core_events, this);
|
||||
|
||||
m_pw.registry = pw_core_get_registry(m_pw.core,
|
||||
PW_VERSION_REGISTRY, 0);
|
||||
|
||||
pw_registry_add_listener(m_pw.registry,
|
||||
&m_pw.registry_listener, ®istry_events, this);
|
||||
|
||||
m_pw.pending_seq = 0;
|
||||
m_pw.last_seq = 0;
|
||||
m_pw.last_res = 0;
|
||||
m_pw.error = false;
|
||||
|
||||
m_running = true;
|
||||
|
||||
pw_thread_loop_start(m_pw.loop);
|
||||
pw_thread_loop_unlock(m_pw.loop);
|
||||
|
||||
fprintf(stderr, "pwweb: PipeWire connected\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
void GraphEngine::close() {
|
||||
if (!m_pw.loop) return;
|
||||
|
||||
m_running = false;
|
||||
|
||||
pw_thread_loop_lock(m_pw.loop);
|
||||
|
||||
clearObjects();
|
||||
|
||||
pw_thread_loop_unlock(m_pw.loop);
|
||||
|
||||
if (m_pw.loop)
|
||||
pw_thread_loop_stop(m_pw.loop);
|
||||
|
||||
if (m_pw.registry) {
|
||||
spa_hook_remove(&m_pw.registry_listener);
|
||||
pw_proxy_destroy((pw_proxy*)m_pw.registry);
|
||||
}
|
||||
|
||||
if (m_pw.core) {
|
||||
spa_hook_remove(&m_pw.core_listener);
|
||||
pw_core_disconnect(m_pw.core);
|
||||
}
|
||||
|
||||
if (m_pw.context)
|
||||
pw_context_destroy(m_pw.context);
|
||||
|
||||
if (m_pw.loop)
|
||||
pw_thread_loop_destroy(m_pw.loop);
|
||||
|
||||
memset(&m_pw, 0, sizeof(m_pw));
|
||||
|
||||
pw_deinit();
|
||||
|
||||
fprintf(stderr, "pwweb: PipeWire disconnected\n");
|
||||
}
|
||||
|
||||
void GraphEngine::setOnChange(ChangeCallback cb) {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_on_change = std::move(cb);
|
||||
}
|
||||
|
||||
void GraphEngine::notifyChanged() {
|
||||
// Called from PipeWire thread — invoke callback outside lock
|
||||
ChangeCallback cb;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
cb = m_on_change;
|
||||
}
|
||||
if (cb) cb();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Object management
|
||||
// ============================================================================
|
||||
|
||||
void GraphEngine::addObject(uint32_t id, Object *obj) {
|
||||
m_objects_by_id[id] = obj;
|
||||
m_objects.push_back(obj);
|
||||
}
|
||||
|
||||
GraphEngine::Object *GraphEngine::findObject(uint32_t id) const {
|
||||
auto it = m_objects_by_id.find(id);
|
||||
return (it != m_objects_by_id.end()) ? it->second : nullptr;
|
||||
}
|
||||
|
||||
void GraphEngine::removeObject(uint32_t id) {
|
||||
auto it = m_objects_by_id.find(id);
|
||||
if (it == m_objects_by_id.end()) return;
|
||||
|
||||
Object *obj = it->second;
|
||||
m_objects_by_id.erase(it);
|
||||
|
||||
auto vit = std::find(m_objects.begin(), m_objects.end(), obj);
|
||||
if (vit != m_objects.end())
|
||||
m_objects.erase(vit);
|
||||
|
||||
// If it's a port, remove from parent node's port list
|
||||
if (obj->type == Object::ObjPort) {
|
||||
auto *pobj = static_cast<PortObj*>(obj);
|
||||
auto *nobj = findNode(pobj->port.node_id);
|
||||
if (nobj) {
|
||||
auto &pids = nobj->node.port_ids;
|
||||
pids.erase(std::remove(pids.begin(), pids.end(), id), pids.end());
|
||||
nobj->node.changed = true;
|
||||
}
|
||||
// Remove any links involving this port
|
||||
auto link_ids_copy = pobj->port.link_ids;
|
||||
for (uint32_t lid : link_ids_copy) {
|
||||
removeObject(lid);
|
||||
}
|
||||
}
|
||||
|
||||
// If it's a node, remove all its ports
|
||||
if (obj->type == Object::ObjNode) {
|
||||
auto *nobj = static_cast<NodeObj*>(obj);
|
||||
auto port_ids_copy = nobj->node.port_ids;
|
||||
for (uint32_t pid : port_ids_copy) {
|
||||
removeObject(pid);
|
||||
}
|
||||
}
|
||||
|
||||
// If it's a link, remove from output port's link list
|
||||
if (obj->type == Object::ObjLink) {
|
||||
auto *lobj = static_cast<LinkObj*>(obj);
|
||||
auto *pobj = findPort(lobj->link.port1_id);
|
||||
if (pobj) {
|
||||
auto &lids = pobj->port.link_ids;
|
||||
lids.erase(std::remove(lids.begin(), lids.end(), id), lids.end());
|
||||
}
|
||||
}
|
||||
|
||||
delete obj;
|
||||
}
|
||||
|
||||
void GraphEngine::clearObjects() {
|
||||
for (auto *obj : m_objects)
|
||||
delete obj;
|
||||
m_objects.clear();
|
||||
m_objects_by_id.clear();
|
||||
}
|
||||
|
||||
GraphEngine::NodeObj *GraphEngine::findNode(uint32_t node_id) const {
|
||||
Object *obj = findObject(node_id);
|
||||
return (obj && obj->type == Object::ObjNode) ? static_cast<NodeObj*>(obj) : nullptr;
|
||||
}
|
||||
|
||||
GraphEngine::PortObj *GraphEngine::findPort(uint32_t port_id) const {
|
||||
Object *obj = findObject(port_id);
|
||||
return (obj && obj->type == Object::ObjPort) ? static_cast<PortObj*>(obj) : nullptr;
|
||||
}
|
||||
|
||||
GraphEngine::LinkObj *GraphEngine::findLink(uint32_t link_id) const {
|
||||
Object *obj = findObject(link_id);
|
||||
return (obj && obj->type == Object::ObjLink) ? static_cast<LinkObj*>(obj) : nullptr;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Snapshot (thread-safe graph state)
|
||||
// ============================================================================
|
||||
|
||||
GraphEngine::Snapshot GraphEngine::snapshot() const {
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
|
||||
Snapshot snap;
|
||||
for (auto *obj : m_objects) {
|
||||
switch (obj->type) {
|
||||
case Object::ObjNode:
|
||||
snap.nodes.push_back(static_cast<NodeObj*>(obj)->node);
|
||||
break;
|
||||
case Object::ObjPort:
|
||||
snap.ports.push_back(static_cast<PortObj*>(obj)->port);
|
||||
break;
|
||||
case Object::ObjLink:
|
||||
snap.links.push_back(static_cast<LinkObj*>(obj)->link);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return snap;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connect/Disconnect (ported from qpwgraph_pipewire::connectPorts)
|
||||
// ============================================================================
|
||||
|
||||
bool GraphEngine::connectPorts(uint32_t output_port_id, uint32_t input_port_id) {
|
||||
if (!m_pw.loop) return false;
|
||||
|
||||
pw_thread_loop_lock(m_pw.loop);
|
||||
|
||||
PortObj *p1 = findPort(output_port_id);
|
||||
PortObj *p2 = findPort(input_port_id);
|
||||
|
||||
if (!p1 || !p2 ||
|
||||
(p1->port.mode & PortMode::Output) == PortMode::None ||
|
||||
(p2->port.mode & PortMode::Input) == PortMode::None ||
|
||||
p1->port.port_type != p2->port.port_type) {
|
||||
pw_thread_loop_unlock(m_pw.loop);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create link via PipeWire (adapted from qpwgraph lines 858-891)
|
||||
char val[4][16];
|
||||
snprintf(val[0], sizeof(val[0]), "%u", p1->port.node_id);
|
||||
snprintf(val[1], sizeof(val[1]), "%u", p1->id);
|
||||
snprintf(val[2], sizeof(val[2]), "%u", p2->port.node_id);
|
||||
snprintf(val[3], sizeof(val[3]), "%u", p2->id);
|
||||
|
||||
struct spa_dict props;
|
||||
struct spa_dict_item items[6];
|
||||
props = SPA_DICT_INIT(items, 0);
|
||||
items[props.n_items++] = SPA_DICT_ITEM_INIT(PW_KEY_LINK_OUTPUT_NODE, val[0]);
|
||||
items[props.n_items++] = SPA_DICT_ITEM_INIT(PW_KEY_LINK_OUTPUT_PORT, val[1]);
|
||||
items[props.n_items++] = SPA_DICT_ITEM_INIT(PW_KEY_LINK_INPUT_NODE, val[2]);
|
||||
items[props.n_items++] = SPA_DICT_ITEM_INIT(PW_KEY_LINK_INPUT_PORT, val[3]);
|
||||
items[props.n_items++] = SPA_DICT_ITEM_INIT(PW_KEY_OBJECT_LINGER, "true");
|
||||
const char *str = getenv("PIPEWIRE_LINK_PASSIVE");
|
||||
if (str && pw_properties_parse_bool(str))
|
||||
items[props.n_items++] = SPA_DICT_ITEM_INIT(PW_KEY_LINK_PASSIVE, "true");
|
||||
|
||||
pw_proxy *proxy = (pw_proxy*)pw_core_create_object(m_pw.core,
|
||||
"link-factory", PW_TYPE_INTERFACE_Link, PW_VERSION_LINK, &props, 0);
|
||||
|
||||
if (proxy) {
|
||||
int link_res = 0;
|
||||
spa_hook listener;
|
||||
spa_zero(listener);
|
||||
pw_proxy_add_listener(proxy,
|
||||
&listener, &link_proxy_events, &link_res);
|
||||
link_proxy_sync(this);
|
||||
spa_hook_remove(&listener);
|
||||
pw_proxy_destroy(proxy);
|
||||
}
|
||||
|
||||
pw_thread_loop_unlock(m_pw.loop);
|
||||
return (proxy != nullptr);
|
||||
}
|
||||
|
||||
bool GraphEngine::disconnectPorts(uint32_t output_port_id, uint32_t input_port_id) {
|
||||
if (!m_pw.loop) return false;
|
||||
|
||||
pw_thread_loop_lock(m_pw.loop);
|
||||
|
||||
PortObj *p1 = findPort(output_port_id);
|
||||
PortObj *p2 = findPort(input_port_id);
|
||||
|
||||
if (!p1 || !p2) {
|
||||
pw_thread_loop_unlock(m_pw.loop);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find and destroy matching link (adapted from qpwgraph lines 846-853)
|
||||
bool found = false;
|
||||
for (uint32_t lid : p1->port.link_ids) {
|
||||
LinkObj *link = findLink(lid);
|
||||
if (link && link->link.port1_id == output_port_id &&
|
||||
link->link.port2_id == input_port_id) {
|
||||
pw_registry_destroy(m_pw.registry, lid);
|
||||
link_proxy_sync(this);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
pw_thread_loop_unlock(m_pw.loop);
|
||||
return found;
|
||||
}
|
||||
|
||||
// end of graph_engine.cpp
|
||||
131
src/graph_engine.h
Normal file
131
src/graph_engine.h
Normal file
@@ -0,0 +1,131 @@
|
||||
#pragma once
|
||||
|
||||
#include "graph_types.h"
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
#include <unordered_map>
|
||||
#include <thread>
|
||||
#include <atomic>
|
||||
|
||||
#include <pipewire/utils.h> // pw_thread_loop, etc.
|
||||
#include <spa/utils/list.h> // spa_list
|
||||
#include <spa/utils/hook.h> // spa_hook
|
||||
|
||||
struct pw_thread_loop;
|
||||
struct pw_context;
|
||||
struct pw_core;
|
||||
struct pw_registry;
|
||||
|
||||
namespace pwgraph {
|
||||
|
||||
class GraphEngine {
|
||||
public:
|
||||
using ChangeCallback = std::function<void()>;
|
||||
|
||||
GraphEngine();
|
||||
~GraphEngine();
|
||||
|
||||
bool open();
|
||||
void close();
|
||||
|
||||
// Set callback invoked when graph changes
|
||||
void setOnChange(ChangeCallback cb);
|
||||
|
||||
// Thread-safe snapshot of the current graph state
|
||||
struct Snapshot {
|
||||
std::vector<Node> nodes;
|
||||
std::vector<Port> ports;
|
||||
std::vector<Link> links;
|
||||
};
|
||||
Snapshot snapshot() const;
|
||||
|
||||
// Connect/disconnect two ports (thread-safe, blocks until done)
|
||||
bool connectPorts(uint32_t output_port_id, uint32_t input_port_id);
|
||||
bool disconnectPorts(uint32_t output_port_id, uint32_t input_port_id);
|
||||
|
||||
// PipeWire internal data (exposed for C callbacks)
|
||||
struct PwData {
|
||||
pw_thread_loop *loop;
|
||||
pw_context *context;
|
||||
pw_core *core;
|
||||
spa_hook core_listener;
|
||||
pw_registry *registry;
|
||||
spa_hook registry_listener;
|
||||
int pending_seq;
|
||||
spa_list pending; // doubly-linked list head for pending syncs
|
||||
int last_seq;
|
||||
int last_res;
|
||||
bool error;
|
||||
};
|
||||
|
||||
PwData& pwData() { return m_pw; }
|
||||
|
||||
// Object management (called from C callbacks)
|
||||
struct Object {
|
||||
enum Type { ObjNode, ObjPort, ObjLink };
|
||||
uint32_t id;
|
||||
Type type;
|
||||
void *proxy;
|
||||
void *info;
|
||||
void (*destroy_info)(void*);
|
||||
spa_hook proxy_listener;
|
||||
spa_hook object_listener;
|
||||
int pending_seq;
|
||||
spa_list pending_link;
|
||||
|
||||
Object(uint32_t id, Type type);
|
||||
virtual ~Object();
|
||||
};
|
||||
|
||||
struct NodeObj : Object {
|
||||
NodeObj(uint32_t id) : Object(id, ObjNode) {}
|
||||
Node node;
|
||||
};
|
||||
|
||||
struct PortObj : Object {
|
||||
PortObj(uint32_t id) : Object(id, ObjPort) {}
|
||||
Port port;
|
||||
};
|
||||
|
||||
struct LinkObj : Object {
|
||||
LinkObj(uint32_t id) : Object(id, ObjLink) {}
|
||||
Link link;
|
||||
};
|
||||
|
||||
void addObject(uint32_t id, Object *obj);
|
||||
Object *findObject(uint32_t id) const;
|
||||
void removeObject(uint32_t id);
|
||||
void clearObjects();
|
||||
|
||||
NodeObj *findNode(uint32_t node_id) const;
|
||||
PortObj *findPort(uint32_t port_id) const;
|
||||
LinkObj *findLink(uint32_t link_id) const;
|
||||
|
||||
void notifyChanged();
|
||||
|
||||
private:
|
||||
PwData m_pw;
|
||||
|
||||
mutable std::mutex m_mutex; // protects m_objects and graph data
|
||||
std::unordered_map<uint32_t, Object*> m_objects_by_id;
|
||||
std::vector<Object*> m_objects;
|
||||
|
||||
ChangeCallback m_on_change;
|
||||
std::atomic<bool> m_running;
|
||||
|
||||
// Port type hashes
|
||||
uint32_t m_audio_type;
|
||||
uint32_t m_midi_type;
|
||||
uint32_t m_midi2_type;
|
||||
uint32_t m_video_type;
|
||||
uint32_t m_other_type;
|
||||
|
||||
public:
|
||||
uint32_t audioPortType() const { return m_audio_type; }
|
||||
uint32_t midiPortType() const { return m_midi_type; }
|
||||
uint32_t videoPortType() const { return m_video_type; }
|
||||
uint32_t otherPortType() const { return m_other_type; }
|
||||
};
|
||||
|
||||
} // namespace pwgraph
|
||||
105
src/graph_types.h
Normal file
105
src/graph_types.h
Normal file
@@ -0,0 +1,105 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <unordered_map>
|
||||
#include <cstdint>
|
||||
|
||||
namespace pwgraph {
|
||||
|
||||
enum class PortMode : uint8_t {
|
||||
None = 0,
|
||||
Input = 1,
|
||||
Output = 2,
|
||||
Duplex = Input | Output
|
||||
};
|
||||
|
||||
enum class NodeType : uint8_t {
|
||||
None = 0,
|
||||
Audio = 1,
|
||||
Video = 2,
|
||||
Midi = 4
|
||||
};
|
||||
|
||||
enum class PortFlags : uint8_t {
|
||||
None = 0,
|
||||
Physical = 1,
|
||||
Terminal = 2,
|
||||
Monitor = 4,
|
||||
Control = 8
|
||||
};
|
||||
|
||||
inline PortFlags operator|(PortFlags a, PortFlags b) {
|
||||
return static_cast<PortFlags>(static_cast<uint8_t>(a) | static_cast<uint8_t>(b));
|
||||
}
|
||||
inline PortFlags operator&(PortFlags a, PortFlags b) {
|
||||
return static_cast<PortFlags>(static_cast<uint8_t>(a) & static_cast<uint8_t>(b));
|
||||
}
|
||||
|
||||
// Bitwise operators for PortMode
|
||||
inline PortMode operator|(PortMode a, PortMode b) {
|
||||
return static_cast<PortMode>(static_cast<uint8_t>(a) | static_cast<uint8_t>(b));
|
||||
}
|
||||
inline PortMode operator&(PortMode a, PortMode b) {
|
||||
return static_cast<PortMode>(static_cast<uint8_t>(a) & static_cast<uint8_t>(b));
|
||||
}
|
||||
|
||||
// Bitwise operators for NodeType
|
||||
inline NodeType operator|(NodeType a, NodeType b) {
|
||||
return static_cast<NodeType>(static_cast<uint8_t>(a) | static_cast<uint8_t>(b));
|
||||
}
|
||||
inline NodeType operator&(NodeType a, NodeType b) {
|
||||
return static_cast<NodeType>(static_cast<uint8_t>(a) & static_cast<uint8_t>(b));
|
||||
}
|
||||
|
||||
struct Node {
|
||||
uint32_t id;
|
||||
std::string name;
|
||||
std::string nick;
|
||||
std::string media_name;
|
||||
PortMode mode; // from media class (Source=Output, Sink=Input)
|
||||
NodeType node_type;
|
||||
PortMode mode2; // derived from actual port directions
|
||||
bool ready;
|
||||
bool changed;
|
||||
|
||||
std::vector<uint32_t> port_ids; // child port IDs
|
||||
|
||||
Node() : id(0), mode(PortMode::None), node_type(NodeType::None),
|
||||
mode2(PortMode::None), ready(false), changed(false) {}
|
||||
};
|
||||
|
||||
struct Link;
|
||||
|
||||
struct Port {
|
||||
uint32_t id;
|
||||
uint32_t node_id;
|
||||
std::string name;
|
||||
PortMode mode;
|
||||
uint32_t port_type; // hashed type string
|
||||
PortFlags flags;
|
||||
|
||||
std::vector<uint32_t> link_ids; // links where this is the output port
|
||||
|
||||
Port() : id(0), node_id(0), mode(PortMode::None),
|
||||
port_type(0), flags(PortFlags::None) {}
|
||||
};
|
||||
|
||||
struct Link {
|
||||
uint32_t id;
|
||||
uint32_t port1_id; // output port
|
||||
uint32_t port2_id; // input port
|
||||
|
||||
Link() : id(0), port1_id(0), port2_id(0) {}
|
||||
};
|
||||
|
||||
// Simple hash function for port type strings (matches qpwgraph_item::itemType)
|
||||
inline uint32_t hashType(const std::string& s) {
|
||||
uint32_t h = 5381;
|
||||
for (char c : s) {
|
||||
h = ((h << 5) + h) + static_cast<uint32_t>(c);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
} // namespace pwgraph
|
||||
69
src/main.cpp
Normal file
69
src/main.cpp
Normal file
@@ -0,0 +1,69 @@
|
||||
#include "graph_engine.h"
|
||||
#include "web_server.h"
|
||||
|
||||
#include <csignal>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <unistd.h>
|
||||
#include <atomic>
|
||||
|
||||
static std::atomic<bool> g_running(true);
|
||||
|
||||
static void signal_handler(int sig) {
|
||||
g_running = false;
|
||||
fprintf(stderr, "\npwweb: caught signal %d, shutting down...\n", sig);
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
int port = 9876;
|
||||
|
||||
// Parse optional port argument
|
||||
if (argc > 1) {
|
||||
port = atoi(argv[1]);
|
||||
if (port <= 0 || port > 65535) {
|
||||
fprintf(stderr, "usage: pwweb [port]\n");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
signal(SIGINT, signal_handler);
|
||||
signal(SIGTERM, signal_handler);
|
||||
|
||||
pwgraph::GraphEngine engine;
|
||||
|
||||
fprintf(stderr, "pwweb: connecting to PipeWire...\n");
|
||||
if (!engine.open()) {
|
||||
fprintf(stderr, "pwweb: failed to connect to PipeWire.\n");
|
||||
fprintf(stderr, " Make sure XDG_RUNTIME_DIR is set: export XDG_RUNTIME_DIR=/run/user/$(id -u)\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
pwgraph::WebServer server(engine, port);
|
||||
|
||||
// Wire change notifications to broadcast
|
||||
engine.setOnChange([&server]() {
|
||||
server.broadcastGraph();
|
||||
});
|
||||
|
||||
if (!server.start()) {
|
||||
fprintf(stderr, "pwweb: failed to start web server on port %d\n", port);
|
||||
engine.close();
|
||||
return 1;
|
||||
}
|
||||
|
||||
fprintf(stderr, "pwweb: server running at http://localhost:%d\n", port);
|
||||
fprintf(stderr, "pwweb: API at http://localhost:%d/api/graph\n", port);
|
||||
fprintf(stderr, "pwweb: press Ctrl+C to stop\n");
|
||||
|
||||
// Main loop — just wait for shutdown signal
|
||||
while (g_running) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
||||
}
|
||||
|
||||
fprintf(stderr, "pwweb: shutting down...\n");
|
||||
server.stop();
|
||||
engine.close();
|
||||
|
||||
fprintf(stderr, "pwweb: goodbye.\n");
|
||||
return 0;
|
||||
}
|
||||
20393
src/third_party/httplib.h
vendored
Normal file
20393
src/third_party/httplib.h
vendored
Normal file
File diff suppressed because it is too large
Load Diff
314
src/web_server.cpp
Normal file
314
src/web_server.cpp
Normal file
@@ -0,0 +1,314 @@
|
||||
#include "web_server.h"
|
||||
#include <sstream>
|
||||
#include <iomanip>
|
||||
#include <cstdio>
|
||||
#include <algorithm>
|
||||
#include <condition_variable>
|
||||
|
||||
using namespace pwgraph;
|
||||
|
||||
// ============================================================================
|
||||
// JSON serialization helpers
|
||||
// ============================================================================
|
||||
|
||||
static std::string escapeJson(const std::string &s) {
|
||||
std::ostringstream o;
|
||||
for (char c : s) {
|
||||
switch (c) {
|
||||
case '"': o << "\\\""; break;
|
||||
case '\\': o << "\\\\"; break;
|
||||
case '\b': o << "\\b"; break;
|
||||
case '\f': o << "\\f"; break;
|
||||
case '\n': o << "\\n"; break;
|
||||
case '\r': o << "\\r"; break;
|
||||
case '\t': o << "\\t"; break;
|
||||
default:
|
||||
if (static_cast<unsigned char>(c) < 0x20) {
|
||||
o << "\\u" << std::hex << std::setw(4) << std::setfill('0')
|
||||
<< (int)(unsigned char)c;
|
||||
} else {
|
||||
o << c;
|
||||
}
|
||||
}
|
||||
}
|
||||
return o.str();
|
||||
}
|
||||
|
||||
static const char *portModeStr(PortMode m) {
|
||||
switch (m) {
|
||||
case PortMode::Input: return "input";
|
||||
case PortMode::Output: return "output";
|
||||
case PortMode::Duplex: return "duplex";
|
||||
default: return "none";
|
||||
}
|
||||
}
|
||||
|
||||
static std::string nodeTypeStr(NodeType t) {
|
||||
std::string result;
|
||||
if ((t & NodeType::Audio) != NodeType::None) {
|
||||
if (!result.empty()) result += '+';
|
||||
result += "audio";
|
||||
}
|
||||
if ((t & NodeType::Video) != NodeType::None) {
|
||||
if (!result.empty()) result += '+';
|
||||
result += "video";
|
||||
}
|
||||
if ((t & NodeType::Midi) != NodeType::None) {
|
||||
if (!result.empty()) result += '+';
|
||||
result += "midi";
|
||||
}
|
||||
return result.empty() ? "other" : result;
|
||||
}
|
||||
|
||||
std::string WebServer::buildGraphJson() const {
|
||||
auto snap = m_engine.snapshot();
|
||||
|
||||
std::ostringstream json;
|
||||
json << "{\"type\":\"graph\",\"nodes\":[";
|
||||
|
||||
bool first_node = true;
|
||||
for (auto &n : snap.nodes) {
|
||||
if (!n.ready) continue;
|
||||
if (!first_node) json << ",";
|
||||
first_node = false;
|
||||
|
||||
json << "{\"id\":" << n.id
|
||||
<< ",\"name\":\"" << escapeJson(n.name) << "\""
|
||||
<< ",\"nick\":\"" << escapeJson(n.nick) << "\""
|
||||
<< ",\"media_name\":\"" << escapeJson(n.media_name) << "\""
|
||||
<< ",\"mode\":\"" << portModeStr(n.mode) << "\""
|
||||
<< ",\"node_type\":\"" << nodeTypeStr(n.node_type) << "\""
|
||||
<< ",\"port_ids\":[";
|
||||
bool first_p = true;
|
||||
for (uint32_t pid : n.port_ids) {
|
||||
if (!first_p) json << ",";
|
||||
first_p = false;
|
||||
json << pid;
|
||||
}
|
||||
json << "]}";
|
||||
}
|
||||
|
||||
json << "],\"ports\":[";
|
||||
|
||||
bool first_port = true;
|
||||
for (auto &p : snap.ports) {
|
||||
if (!first_port) json << ",";
|
||||
first_port = false;
|
||||
|
||||
std::string port_type_name;
|
||||
uint32_t pt = p.port_type;
|
||||
if (pt == m_engine.audioPortType()) port_type_name = "audio";
|
||||
else if (pt == m_engine.midiPortType()) port_type_name = "midi";
|
||||
else if (pt == m_engine.videoPortType()) port_type_name = "video";
|
||||
else port_type_name = "other";
|
||||
|
||||
json << "{\"id\":" << p.id
|
||||
<< ",\"node_id\":" << p.node_id
|
||||
<< ",\"name\":\"" << escapeJson(p.name) << "\""
|
||||
<< ",\"mode\":\"" << portModeStr(p.mode) << "\""
|
||||
<< ",\"port_type\":\"" << port_type_name << "\""
|
||||
<< ",\"flags\":" << (int)(uint8_t)p.flags
|
||||
<< "}";
|
||||
}
|
||||
|
||||
json << "],\"links\":[";
|
||||
|
||||
bool first_link = true;
|
||||
for (auto &l : snap.links) {
|
||||
if (!first_link) json << ",";
|
||||
first_link = false;
|
||||
|
||||
json << "{\"id\":" << l.id
|
||||
<< ",\"output_port_id\":" << l.port1_id
|
||||
<< ",\"input_port_id\":" << l.port2_id << "}";
|
||||
}
|
||||
|
||||
json << "]}";
|
||||
return json.str();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WebServer
|
||||
// ============================================================================
|
||||
|
||||
WebServer::WebServer(GraphEngine &engine, int port)
|
||||
: m_engine(engine), m_port(port), m_running(false)
|
||||
{
|
||||
}
|
||||
|
||||
WebServer::~WebServer() {
|
||||
stop();
|
||||
}
|
||||
|
||||
bool WebServer::start() {
|
||||
setupRoutes();
|
||||
|
||||
m_running = true;
|
||||
m_thread = std::thread([this]() {
|
||||
fprintf(stderr, "pwweb: starting web server on port %d\n", m_port);
|
||||
if (!m_http.listen("0.0.0.0", m_port)) {
|
||||
fprintf(stderr, "pwweb: failed to bind to port %d\n", m_port);
|
||||
m_running = false;
|
||||
}
|
||||
});
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
||||
return m_running;
|
||||
}
|
||||
|
||||
void WebServer::stop() {
|
||||
if (m_running) {
|
||||
m_running = false;
|
||||
// Close all SSE clients so their loops exit
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_sse_mutex);
|
||||
for (auto *sink : m_sse_clients)
|
||||
sink->done();
|
||||
m_sse_clients.clear();
|
||||
}
|
||||
m_http.stop();
|
||||
if (m_thread.joinable())
|
||||
m_thread.join();
|
||||
}
|
||||
}
|
||||
|
||||
void WebServer::broadcastGraph() {
|
||||
if (!m_running) return;
|
||||
std::string json = buildGraphJson();
|
||||
|
||||
std::lock_guard<std::mutex> lock(m_sse_mutex);
|
||||
for (auto it = m_sse_clients.begin(); it != m_sse_clients.end(); ) {
|
||||
auto *sink = *it;
|
||||
std::string msg = "data: " + json + "\n\n";
|
||||
if (sink->write(msg.c_str(), msg.size())) {
|
||||
++it;
|
||||
} else {
|
||||
it = m_sse_clients.erase(it);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void WebServer::setupRoutes() {
|
||||
// Serve frontend static files from ./frontend/dist
|
||||
m_http.set_mount_point("/", "./frontend/dist");
|
||||
|
||||
// SSE endpoint: long-lived event stream
|
||||
m_http.Get("/events", [this](const httplib::Request &req, httplib::Response &res) {
|
||||
res.set_header("Content-Type", "text/event-stream");
|
||||
res.set_header("Cache-Control", "no-cache");
|
||||
res.set_header("Connection", "keep-alive");
|
||||
res.set_header("Access-Control-Allow-Origin", "*");
|
||||
|
||||
res.set_content_provider(
|
||||
"text/event-stream",
|
||||
[this](size_t /*offset*/, httplib::DataSink &sink) {
|
||||
// Register this SSE client
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_sse_mutex);
|
||||
m_sse_clients.insert(&sink);
|
||||
}
|
||||
fprintf(stderr, "pwweb: SSE client connected (total: %zu)\n",
|
||||
m_sse_clients.size());
|
||||
|
||||
// Send initial graph state
|
||||
{
|
||||
std::string json = buildGraphJson();
|
||||
std::string msg = "data: " + json + "\n\n";
|
||||
sink.write(msg.c_str(), msg.size());
|
||||
}
|
||||
|
||||
// Block until connection closes or server stops
|
||||
std::mutex mtx;
|
||||
std::unique_lock<std::mutex> lock(mtx);
|
||||
std::condition_variable cv;
|
||||
auto sink_ptr = &sink;
|
||||
|
||||
// Poll: check if sink is still open, broadcast triggers via broadcastGraph()
|
||||
while (m_running && sink.write("", 0)) {
|
||||
// write with 0 bytes keeps connection alive; sleep between checks
|
||||
// Actually, we need to block. Let's use a simpler approach:
|
||||
// just block on a condition variable that gets notified
|
||||
cv.wait_for(lock, std::chrono::seconds(30), [&] {
|
||||
return !m_running;
|
||||
});
|
||||
// Send a SSE comment as keepalive
|
||||
if (m_running) {
|
||||
std::string keepalive = ": keepalive\n\n";
|
||||
if (!sink.write(keepalive.c_str(), keepalive.size()))
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Unregister
|
||||
{
|
||||
std::lock_guard<std::mutex> l(m_sse_mutex);
|
||||
m_sse_clients.erase(sink_ptr);
|
||||
}
|
||||
fprintf(stderr, "pwweb: SSE client disconnected (remaining: %zu)\n",
|
||||
m_sse_clients.size());
|
||||
|
||||
sink.done();
|
||||
return false; // stop calling this callback
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// REST API: GET /api/graph
|
||||
m_http.Get("/api/graph", [this](const httplib::Request &, httplib::Response &res) {
|
||||
std::string json = buildGraphJson();
|
||||
res.set_content(json, "application/json");
|
||||
res.set_header("Access-Control-Allow-Origin", "*");
|
||||
});
|
||||
|
||||
// REST API: POST /api/connect
|
||||
m_http.Post("/api/connect", [this](const httplib::Request &req, httplib::Response &res) {
|
||||
uint32_t out_id = 0, in_id = 0;
|
||||
if (sscanf(req.body.c_str(),
|
||||
"{\"output_port_id\":%u,\"input_port_id\":%u}", &out_id, &in_id) == 2 ||
|
||||
sscanf(req.body.c_str(),
|
||||
"{\"output_port_id\":%u, \"input_port_id\":%u}", &out_id, &in_id) == 2)
|
||||
{
|
||||
bool ok = m_engine.connectPorts(out_id, in_id);
|
||||
if (ok) broadcastGraph();
|
||||
res.set_content(
|
||||
ok ? "{\"ok\":true}" : "{\"ok\":false,\"error\":\"connect failed\"}",
|
||||
"application/json");
|
||||
} else {
|
||||
res.status = 400;
|
||||
res.set_content("{\"error\":\"invalid json\"}", "application/json");
|
||||
}
|
||||
res.set_header("Access-Control-Allow-Origin", "*");
|
||||
});
|
||||
|
||||
// REST API: POST /api/disconnect
|
||||
m_http.Post("/api/disconnect", [this](const httplib::Request &req, httplib::Response &res) {
|
||||
uint32_t out_id = 0, in_id = 0;
|
||||
if (sscanf(req.body.c_str(),
|
||||
"{\"output_port_id\":%u,\"input_port_id\":%u}", &out_id, &in_id) == 2 ||
|
||||
sscanf(req.body.c_str(),
|
||||
"{\"output_port_id\":%u, \"input_port_id\":%u}", &out_id, &in_id) == 2)
|
||||
{
|
||||
bool ok = m_engine.disconnectPorts(out_id, in_id);
|
||||
if (ok) broadcastGraph();
|
||||
res.set_content(
|
||||
ok ? "{\"ok\":true}" : "{\"ok\":false,\"error\":\"disconnect failed\"}",
|
||||
"application/json");
|
||||
} else {
|
||||
res.status = 400;
|
||||
res.set_content("{\"error\":\"invalid json\"}", "application/json");
|
||||
}
|
||||
res.set_header("Access-Control-Allow-Origin", "*");
|
||||
});
|
||||
|
||||
// CORS preflight
|
||||
auto cors_handler = [](const httplib::Request &, httplib::Response &res) {
|
||||
res.set_header("Access-Control-Allow-Origin", "*");
|
||||
res.set_header("Access-Control-Allow-Methods", "POST, OPTIONS");
|
||||
res.set_header("Access-Control-Allow-Headers", "Content-Type");
|
||||
res.status = 204;
|
||||
};
|
||||
m_http.Options("/api/connect", cors_handler);
|
||||
m_http.Options("/api/disconnect", cors_handler);
|
||||
}
|
||||
|
||||
// end of web_server.cpp
|
||||
39
src/web_server.h
Normal file
39
src/web_server.h
Normal file
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#include "graph_engine.h"
|
||||
#include <httplib.h>
|
||||
#include <thread>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
|
||||
namespace pwgraph {
|
||||
|
||||
class WebServer {
|
||||
public:
|
||||
WebServer(GraphEngine &engine, int port = 9876);
|
||||
~WebServer();
|
||||
|
||||
bool start();
|
||||
void stop();
|
||||
|
||||
// Broadcast graph update to all connected SSE clients
|
||||
void broadcastGraph();
|
||||
|
||||
private:
|
||||
void setupRoutes();
|
||||
std::string buildGraphJson() const;
|
||||
|
||||
GraphEngine &m_engine;
|
||||
int m_port;
|
||||
httplib::Server m_http;
|
||||
std::thread m_thread;
|
||||
std::atomic<bool> m_running;
|
||||
|
||||
// SSE clients
|
||||
mutable std::mutex m_sse_mutex;
|
||||
std::set<httplib::DataSink *> m_sse_clients;
|
||||
};
|
||||
|
||||
} // namespace pwgraph
|
||||
Reference in New Issue
Block a user