#include "web_server.h" #include #include #include #include #include #include #include #include using namespace pwgraph; // ============================================================================ // File I/O helpers // ============================================================================ std::string WebServer::dataDir() const { const char *home = getenv("HOME"); if (!home) { auto *pw = getpwuid(getuid()); if (pw) home = pw->pw_dir; } std::string dir = (home ? home : "/tmp"); dir += "/.config/pwweb"; mkdir(dir.c_str(), 0755); return dir; } std::string WebServer::readFile(const std::string &path) const { std::ifstream f(path); if (!f.is_open()) return "{}"; return std::string((std::istreambuf_iterator(f)), std::istreambuf_iterator()); } void WebServer::writeFile(const std::string &path, const std::string &data) const { std::ofstream f(path); if (f.is_open()) f << data; } // ============================================================================ // 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(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) << "\"" << ",\"volume\":" << n.volume << ",\"mute\":" << (n.mute ? "true" : "false") << ",\"sample_rate\":" << n.sample_rate << ",\"channels\":" << n.channels << ",\"quantum\":" << n.quantum << ",\"rate\":" << n.rate << ",\"format\":\"" << escapeJson(n.format) << "\"" << ",\"device_name\":\"" << escapeJson(n.device_name) << "\"" << ",\"device_bus\":\"" << escapeJson(n.device_bus) << "\"" << ",\"api\":\"" << escapeJson(n.api) << "\"" << ",\"priority\":" << n.priority << ",\"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 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 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 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 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 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); m_http.Options("/api/positions", cors_handler); m_http.Options("/api/patchbay", cors_handler); // Positions persistence: GET /api/positions m_http.Get("/api/positions", [this](const httplib::Request &, httplib::Response &res) { std::string path = dataDir() + "/positions.json"; res.set_content(readFile(path), "application/json"); res.set_header("Access-Control-Allow-Origin", "*"); }); // Positions persistence: PUT /api/positions m_http.Put("/api/positions", [this](const httplib::Request &req, httplib::Response &res) { std::string path = dataDir() + "/positions.json"; writeFile(path, req.body); res.set_content("{\"ok\":true}", "application/json"); res.set_header("Access-Control-Allow-Origin", "*"); }); // Patchbay: GET /api/patchbay m_http.Get("/api/patchbay", [this](const httplib::Request &, httplib::Response &res) { std::string path = dataDir() + "/patchbay.json"; res.set_content(readFile(path), "application/json"); res.set_header("Access-Control-Allow-Origin", "*"); }); // Patchbay: PUT /api/patchbay (save current connections as a named snapshot) m_http.Put("/api/patchbay", [this](const httplib::Request &req, httplib::Response &res) { std::string path = dataDir() + "/patchbay.json"; writeFile(path, req.body); res.set_content("{\"ok\":true}", "application/json"); res.set_header("Access-Control-Allow-Origin", "*"); }); // Patchbay: POST /api/patchbay/apply (apply saved connections to current graph) m_http.Post("/api/patchbay/apply", [this](const httplib::Request &req, httplib::Response &res) { // req.body is a JSON array of {output_port_id, input_port_id} pairs // We just parse it and connect each pair // Simple parsing: find all pairs std::string body = req.body; size_t pos = 0; int connected = 0; while (pos < body.size()) { uint32_t out_id = 0, in_id = 0; int chars = 0; if (sscanf(body.c_str() + pos, "{\"output_port_id\":%u,\"input_port_id\":%u}%n", &out_id, &in_id, &chars) == 2 || sscanf(body.c_str() + pos, "{\"output_port_id\":%u, \"input_port_id\":%u}%n", &out_id, &in_id, &chars) == 2) { if (m_engine.connectPorts(out_id, in_id)) connected++; } pos = body.find('{', pos + 1); if (pos == std::string::npos) break; } if (connected > 0) broadcastGraph(); char buf[64]; snprintf(buf, sizeof(buf), "{\"ok\":true,\"connected\":%d}", connected); res.set_content(buf, "application/json"); res.set_header("Access-Control-Allow-Origin", "*"); }); // Volume: POST /api/volume {"node_id": N, "volume": 0.0-1.0} m_http.Post("/api/volume", [this](const httplib::Request &req, httplib::Response &res) { uint32_t node_id = 0; float volume = 0; if (sscanf(req.body.c_str(), "{\"node_id\":%u,\"volume\":%f}", &node_id, &volume) == 2 || sscanf(req.body.c_str(), "{\"node_id\":%u, \"volume\":%f}", &node_id, &volume) == 2) { if (volume < 0) volume = 0; if (volume > 1.5) volume = 1.5; bool ok = m_engine.setNodeVolume(node_id, volume); if (ok) broadcastGraph(); res.set_content(ok ? "{\"ok\":true}" : "{\"ok\":false}", "application/json"); } else { res.status = 400; res.set_content("{\"error\":\"invalid json\"}", "application/json"); } res.set_header("Access-Control-Allow-Origin", "*"); }); // Mute: POST /api/mute {"node_id": N, "mute": true/false} m_http.Post("/api/mute", [this](const httplib::Request &req, httplib::Response &res) { uint32_t node_id = 0; if (sscanf(req.body.c_str(), "{\"node_id\":%u", &node_id) == 1 || sscanf(req.body.c_str(), "{\"node_id\": %u", &node_id) == 1) { // Check for "mute":true precisely — the old `find("true")` was imprecise bool mute = req.body.find("\"mute\":true") != std::string::npos || req.body.find("\"mute\": true") != std::string::npos; bool ok = m_engine.setNodeMute(node_id, mute); if (ok) broadcastGraph(); res.set_content(ok ? "{\"ok\":true}" : "{\"ok\":false}", "application/json"); } else { res.status = 400; res.set_content("{\"error\":\"invalid json\"}", "application/json"); } res.set_header("Access-Control-Allow-Origin", "*"); }); m_http.Options("/api/volume", cors_handler); m_http.Options("/api/mute", cors_handler); m_http.Options("/api/create-null-sink", cors_handler); m_http.Options("/api/create-loopback", cors_handler); m_http.Options("/api/unload-module", cors_handler); m_http.Options("/api/load-module", cors_handler); m_http.Options("/api/destroy-node", cors_handler); // Destroy node: POST /api/destroy-node {"node_id":N} m_http.Post("/api/destroy-node", [this](const httplib::Request &req, httplib::Response &res) { uint32_t node_id = 0; if (sscanf(req.body.c_str(), "{\"node_id\":%u}", &node_id) == 1) { // Find the module that owns this node and unload it // First try to destroy via registry (works for pw-cli created nodes) auto snap = m_engine.snapshot(); bool found = false; for (auto &n : snap.nodes) { if (n.id == node_id) { found = true; break; } } if (found) { // Use pw-cli to destroy std::string cmd = "pw-cli destroy " + std::to_string(node_id) + " 2>/dev/null"; int ret = system(cmd.c_str()); (void)ret; sleep(1); broadcastGraph(); } res.set_content(found ? "{\"ok\":true}" : "{\"ok\":false,\"error\":\"node not found\"}", "application/json"); } else { res.status = 400; res.set_content("{\"error\":\"invalid json\"}", "application/json"); } res.set_header("Access-Control-Allow-Origin", "*"); }); // Helper: extract a string value from simple JSON auto extractStr = [](const std::string &body, const std::string &key) -> std::string { size_t pos = body.find("\"" + key + "\""); if (pos == std::string::npos) return ""; size_t start = body.find('"', pos + key.size() + 2); if (start == std::string::npos) return ""; start++; size_t end = body.find('"', start); if (end == std::string::npos) return ""; return body.substr(start, end - start); }; // Helper: run pactl load-module and return module ID auto loadModuleViaPactl = [](const std::string &cmd) -> int { FILE *fp = popen(cmd.c_str(), "r"); if (!fp) return -1; char buf[32] = {}; fgets(buf, sizeof(buf), fp); int status = pclose(fp); if (status != 0) return -1; return atoi(buf); }; // Create null sink: POST /api/create-null-sink {"name":"My Sink"} m_http.Post("/api/create-null-sink", [this, extractStr, loadModuleViaPactl](const httplib::Request &req, httplib::Response &res) { std::string name = extractStr(req.body, "name"); if (name.empty()) name = "pwweb-null"; std::string cmd = "pactl load-module module-null-sink sink_name=\"" + name + "\" sink_properties=node.name=\"" + name + "\" 2>/dev/null"; int id = loadModuleViaPactl(cmd); if (id > 0) { sleep(1); broadcastGraph(); } char buf[128]; snprintf(buf, sizeof(buf), "{\"ok\":%s,\"module_id\":%d}", id > 0 ? "true" : "false", id); res.set_content(buf, "application/json"); res.set_header("Access-Control-Allow-Origin", "*"); }); // Create loopback: POST /api/create-loopback {"name":"My Loopback"} m_http.Post("/api/create-loopback", [this, extractStr, loadModuleViaPactl](const httplib::Request &req, httplib::Response &res) { std::string name = extractStr(req.body, "name"); if (name.empty()) name = "pwweb-loopback"; std::string cmd = "pactl load-module module-loopback source=\"" + name + ".monitor\" sink=\"" + name + "\" 2>/dev/null"; int id = loadModuleViaPactl(cmd); // If that fails, try without specific source/sink (generic loopback) if (id <= 0) { cmd = "pactl load-module module-loopback 2>/dev/null"; id = loadModuleViaPactl(cmd); } if (id > 0) { sleep(1); broadcastGraph(); } char buf[128]; snprintf(buf, sizeof(buf), "{\"ok\":%s,\"module_id\":%d}", id > 0 ? "true" : "false", id); res.set_content(buf, "application/json"); res.set_header("Access-Control-Allow-Origin", "*"); }); // Generic module loading: POST /api/load-module {"module":"module-null-sink","args":"key=val ..."} m_http.Post("/api/load-module", [this, extractStr, loadModuleViaPactl](const httplib::Request &req, httplib::Response &res) { std::string module = extractStr(req.body, "module"); std::string args = extractStr(req.body, "args"); if (module.empty()) { res.status = 400; res.set_content("{\"error\":\"module name required\"}", "application/json"); res.set_header("Access-Control-Allow-Origin", "*"); return; } std::string cmd = "pactl load-module " + module; if (!args.empty()) cmd += " " + args; cmd += " 2>/dev/null"; int id = loadModuleViaPactl(cmd); if (id > 0) { sleep(1); broadcastGraph(); } char buf[128]; snprintf(buf, sizeof(buf), "{\"ok\":%s,\"module_id\":%d}", id > 0 ? "true" : "false", id); res.set_content(buf, "application/json"); res.set_header("Access-Control-Allow-Origin", "*"); }); // Unload module: POST /api/unload-module {"module_id":N} m_http.Post("/api/unload-module", [this](const httplib::Request &req, httplib::Response &res) { uint32_t module_id = 0; if (sscanf(req.body.c_str(), "{\"module_id\":%u}", &module_id) == 1) { bool ok = m_engine.unloadModule(module_id); if (ok) broadcastGraph(); res.set_content(ok ? "{\"ok\":true}" : "{\"ok\":false}", "application/json"); } else { res.status = 400; res.set_content("{\"error\":\"invalid json\"}", "application/json"); } res.set_header("Access-Control-Allow-Origin", "*"); }); m_http.Options("/api/quantum", cors_handler); // Get current quantum: GET /api/quantum m_http.Get("/api/quantum", [](const httplib::Request &, httplib::Response &res) { int quantum = 0; FILE *fp = popen("pw-metadata 0 default.clock.quantum 2>/dev/null | grep -oP \"value:'\\K[0-9]+\"", "r"); if (fp) { char buf[32] = {}; if (fgets(buf, sizeof(buf), fp)) quantum = atoi(buf); pclose(fp); } char out[64]; snprintf(out, sizeof(out), "{\"quantum\":%d}", quantum); res.set_content(out, "application/json"); res.set_header("Access-Control-Allow-Origin", "*"); }); // Set quantum: POST /api/quantum {"quantum":256} m_http.Post("/api/quantum", [](const httplib::Request &req, httplib::Response &res) { int quantum = 0; if (sscanf(req.body.c_str(), "{\"quantum\":%d}", &quantum) == 1 && quantum > 0) { char cmd[128]; snprintf(cmd, sizeof(cmd), "pw-metadata 0 default.clock.quantum %d 2>/dev/null", quantum); int ret = system(cmd); (void)ret; char out[64]; snprintf(out, sizeof(out), "{\"ok\":true,\"quantum\":%d}", quantum); res.set_content(out, "application/json"); } else { res.status = 400; res.set_content("{\"error\":\"invalid json\"}", "application/json"); } res.set_header("Access-Control-Allow-Origin", "*"); }); } // end of web_server.cpp