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:
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
|
||||
Reference in New Issue
Block a user