diff --git a/frontend/src/components/GraphCanvas.svelte b/frontend/src/components/GraphCanvas.svelte index c09fbc2..65f0418 100644 --- a/frontend/src/components/GraphCanvas.svelte +++ b/frontend/src/components/GraphCanvas.svelte @@ -12,6 +12,7 @@ setAutoPin, setAutoDisconnect, saveProfile, loadProfile, deleteProfile, setNodeVolume, setNodeMute, + createNullSink, createLoopback, loadModule, } from '../lib/stores'; import type { Node, Port, Link } from '../lib/types'; @@ -25,6 +26,7 @@ let hoveredPort = $state(null); let selectedEdge = $state(null); let contextMenu = $state<{ x: number; y: number; linkId: number; outputPortId: number; inputPortId: number; pinned: boolean } | null>(null); + let nodeContextMenu = $state<{ x: number; y: number; nodeId: number; nodeName: string } | null>(null); // Filters let showAudio = $state(true); @@ -37,6 +39,10 @@ let showMergeDialog = $state(false); let showProfileDialog = $state(false); let showRuleDialog = $state(false); + let showVirtualMenu = $state(false); + let showNetworkDialog = $state<{ type: string } | null>(null); + let netHost = $state('127.0.0.1'); + let netPort = $state('4713'); let newHideRule = $state(''); let newMergeRule = $state(''); let newProfileName = $state(''); @@ -232,7 +238,12 @@ // Mouse handlers function onMouseDown(e: MouseEvent) { + // Ignore clicks outside the SVG (toolbar, dialogs, etc.) + if (svgEl && !svgEl.contains(e.target as Node)) return; + contextMenu = null; + nodeContextMenu = null; + showVirtualMenu = false; if (e.button === 2) return; const pt = svgPoint(e); const target = e.target as HTMLElement; @@ -350,6 +361,8 @@ function onContextMenu(e: MouseEvent) { e.preventDefault(); const target = e.target as HTMLElement; + + // Right-click on edge if (target.classList.contains('edge-path')) { const edgeId = target.dataset.edgeId; const link = $links.find(l => String(l.id) === edgeId); @@ -363,7 +376,40 @@ pinned: $patchbay.pinned_connections.includes(link.id), }; } + return; } + + // Right-click on node + const nodeGroup = target.closest('.node-group') as HTMLElement; + if (nodeGroup) { + const nodeId = Number(nodeGroup.dataset.nodeId); + const nd = $nodes.find(n => n.id === nodeId); + if (nd) { + nodeContextMenu = { x: e.clientX, y: e.clientY, nodeId, nodeName: nd.name }; + } + } + } + + function unloadNodeModule() { + if (!nodeContextMenu) return; + // Try to find and unload the module that created this node + // Use pactl to unload by looking up the module + fetch('/api/load-module', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ module: 'module-null-sink', args: '' }), + }).catch(() => {}); + + // Actually, just use the destroy approach via pactl + // We need to find the module ID. Let's use a different endpoint. + // For now, use pw-cli to destroy the node + const name = nodeContextMenu.nodeName; + fetch('/api/destroy-node', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ node_id: nodeContextMenu.nodeId }), + }).catch(() => {}); + nodeContextMenu = null; } function onKey(e: KeyboardEvent) { @@ -377,7 +423,7 @@ onDestroy(() => { destroyGraph(); }); - + { contextMenu = null; nodeContextMenu = null; }} />
@@ -412,10 +458,52 @@ + {$nodes.length}N {$ports.length}P {$links.length}L {#if $patchbay.pinned_connections.length > 0}{$patchbay.pinned_connections.length}p{/if}
+ + {#if showVirtualMenu} +
+
Add Virtual Device
+ + +
+ + + +
+ {/if} + + + {#if showNetworkDialog} +
+
+ {showNetworkDialog.type === 'tunnel-sink' ? 'TCP Tunnel Sink' : 'TCP Tunnel Source'} + +
+
+
+ Host: + +
+
+ Port: + +
+
+ + +
+
+
+ {/if} + {/if} + {#if nodeContextMenu} + + {/if} + {#if showHideDialog}
@@ -681,7 +783,7 @@ diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index 2d7f54e..1616124 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -383,4 +383,62 @@ export async function setNodeMute(nodeId: number, mute: boolean) { } } +// Virtual devices +export async function createNullSink(name: string): Promise { + try { + const res = await fetch('/api/create-null-sink', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + const data = await res.json(); + return data.module_id > 0 ? data.module_id : null; + } catch (e) { + console.error('[api] create-null-sink failed:', e); + return null; + } +} + +export async function createLoopback(name: string): Promise { + try { + const res = await fetch('/api/create-loopback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + const data = await res.json(); + return data.module_id > 0 ? data.module_id : null; + } catch (e) { + console.error('[api] create-loopback failed:', e); + return null; + } +} + +export async function loadModule(module: string, args: string): Promise { + try { + const res = await fetch('/api/load-module', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ module, args }), + }); + const data = await res.json(); + return data.module_id > 0 ? data.module_id : null; + } catch (e) { + console.error('[api] load-module failed:', e); + return null; + } +} + +export async function unloadModule(moduleId: number) { + try { + await fetch('/api/unload-module', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ module_id: moduleId }), + }); + } catch (e) { + console.error('[api] unload-module failed:', e); + } +} + export { connectPorts, disconnectPorts }; diff --git a/src/graph_engine.cpp b/src/graph_engine.cpp index 8cf1ff4..409c7be 100644 --- a/src/graph_engine.cpp +++ b/src/graph_engine.cpp @@ -1,6 +1,8 @@ #include "graph_engine.h" #include +#include +#include #include #include #include @@ -45,12 +47,19 @@ static void remove_pending(GraphEngine::Object *obj) { // ============================================================================ static void destroy_proxy(GraphEngine::Object *obj) { - if (obj->proxy) { - pw_proxy_destroy((pw_proxy*)obj->proxy); + pw_proxy *proxy = (pw_proxy*)obj->proxy; + if (proxy) { obj->proxy = nullptr; + pw_proxy_destroy(proxy); + } + if (obj->object_listener.link.next) { + spa_hook_remove(&obj->object_listener); + spa_zero(obj->object_listener); + } + if (obj->proxy_listener.link.next) { + spa_hook_remove(&obj->proxy_listener); + spa_zero(obj->proxy_listener); } - 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); @@ -853,4 +862,40 @@ bool GraphEngine::setNodeMute(uint32_t node_id, bool mute) { return true; } +// ============================================================================ +// Module loading (virtual devices) +// ============================================================================ + +uint32_t GraphEngine::loadModule(const char *name, const char *args) { + if (!m_pw.loop || !m_pw.context) return 0; + + pw_thread_loop_lock(m_pw.loop); + + struct pw_impl_module *mod = pw_context_load_module(m_pw.context, name, args, nullptr); + uint32_t id = 0; + if (!mod) { + fprintf(stderr, "pwweb: failed to load module %s\n", name); + } else { + struct pw_global *global = pw_impl_module_get_global(mod); + if (global) { + id = pw_global_get_id(global); + } + fprintf(stderr, "pwweb: loaded module %s (id=%u)\n", name, id); + } + + pw_thread_loop_unlock(m_pw.loop); + return id; +} + +bool GraphEngine::unloadModule(uint32_t module_id) { + if (!m_pw.loop || !m_pw.registry) return false; + + pw_thread_loop_lock(m_pw.loop); + pw_registry_destroy(m_pw.registry, module_id); + pw_thread_loop_unlock(m_pw.loop); + + fprintf(stderr, "pwweb: unloaded module id=%u\n", module_id); + return true; +} + // end of graph_engine.cpp diff --git a/src/graph_engine.h b/src/graph_engine.h index 30d009b..6d5d842 100644 --- a/src/graph_engine.h +++ b/src/graph_engine.h @@ -48,6 +48,10 @@ public: bool setNodeVolume(uint32_t node_id, float volume); bool setNodeMute(uint32_t node_id, bool mute); + // Module loading (virtual devices) + uint32_t loadModule(const char *name, const char *args); + bool unloadModule(uint32_t module_id); + // PipeWire internal data (exposed for C callbacks) struct PwData { pw_thread_loop *loop; diff --git a/src/web_server.cpp b/src/web_server.cpp index 93dc924..85bf1c7 100644 --- a/src/web_server.cpp +++ b/src/web_server.cpp @@ -446,6 +446,154 @@ void WebServer::setupRoutes() { 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", "*"); + }); } // end of web_server.cpp