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:
joren
2026-03-29 22:40:07 +02:00
commit f8c57fbdd3
34 changed files with 24479 additions and 0 deletions

314
src/web_server.cpp Normal file
View 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