Merge feature/virtual-devices into master
This commit is contained in:
@@ -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<number | null>(null);
|
||||
let selectedEdge = $state<string | null>(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(); });
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
<svelte:window onkeydown={onKey} onclick={() => { contextMenu = null; nodeContextMenu = null; }} />
|
||||
|
||||
<div class="wrap">
|
||||
<div class="toolbar">
|
||||
@@ -412,10 +458,52 @@
|
||||
<button onclick={() => { showMergeDialog = !showMergeDialog; showHideDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="Node merging rules">Merge Nodes</button>
|
||||
<button onclick={() => { showRuleDialog = !showRuleDialog; showHideDialog = false; showMergeDialog = false; showProfileDialog = false; }} title="Manage patchbay rules">Rules</button>
|
||||
<button onclick={() => { showProfileDialog = !showProfileDialog; showHideDialog = false; showMergeDialog = false; showRuleDialog = false; }} title="Save/load profiles">Profiles</button>
|
||||
<button onclick={() => { showVirtualMenu = !showVirtualMenu; showHideDialog = false; showMergeDialog = false; showProfileDialog = false; showRuleDialog = false; }} title="Add virtual device">+ Add</button>
|
||||
|
||||
<span class="stats">{$nodes.length}N {$ports.length}P {$links.length}L {#if $patchbay.pinned_connections.length > 0}{$patchbay.pinned_connections.length}p{/if}</span>
|
||||
</div>
|
||||
|
||||
<!-- Virtual device dropdown -->
|
||||
{#if showVirtualMenu}
|
||||
<div class="virt-menu">
|
||||
<div class="virt-header">Add Virtual Device</div>
|
||||
<button onclick={async () => { showVirtualMenu = false; await createNullSink('pwweb-null-' + Date.now().toString(36)); }}>Null Sink (Virtual Output)</button>
|
||||
<button onclick={async () => { showVirtualMenu = false; await createLoopback('pwweb-loop-' + Date.now().toString(36)); }}>Loopback Device</button>
|
||||
<div class="virt-sep"></div>
|
||||
<button onclick={async () => { showVirtualMenu = false; await loadModule('module-native-protocol-tcp', 'auth-anonymous=1 port=' + netPort); }}>TCP Network Server (port {netPort})</button>
|
||||
<button onclick={async () => { showVirtualMenu = false; showNetworkDialog = { type: 'tunnel-sink' }; }}>TCP Tunnel Sink...</button>
|
||||
<button onclick={async () => { showVirtualMenu = false; showNetworkDialog = { type: 'tunnel-source' }; }}>TCP Tunnel Source...</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Network config dialog -->
|
||||
{#if showNetworkDialog}
|
||||
<div class="dialog" style="right:auto;left:50%;top:50%;transform:translate(-50%,-50%);width:300px">
|
||||
<div class="dialog-header">
|
||||
<span>{showNetworkDialog.type === 'tunnel-sink' ? 'TCP Tunnel Sink' : 'TCP Tunnel Source'}</span>
|
||||
<button class="close" onclick={() => { showNetworkDialog = null; }}>X</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="input-row">
|
||||
<span style="font-size:10px;color:#888;width:50px">Host:</span>
|
||||
<input class="dlg-input" bind:value={netHost} placeholder="127.0.0.1" />
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<span style="font-size:10px;color:#888;width:50px">Port:</span>
|
||||
<input class="dlg-input" bind:value={netPort} placeholder="4713" />
|
||||
</div>
|
||||
<div class="input-row" style="justify-content:flex-end">
|
||||
<button onclick={() => { showNetworkDialog = null; }}>Cancel</button>
|
||||
<button onclick={() => {
|
||||
const mod = showNetworkDialog!.type === 'tunnel-sink' ? 'module-tunnel-sink' : 'module-tunnel-source';
|
||||
showNetworkDialog = null;
|
||||
loadModule(mod, 'server=tcp:' + netHost + ':' + netPort);
|
||||
}}>Connect</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<svg
|
||||
bind:this={svgEl}
|
||||
@@ -567,6 +655,20 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if nodeContextMenu}
|
||||
<div class="ctx" style="left:{nodeContextMenu.x}px;top:{nodeContextMenu.y}px" role="menu">
|
||||
<div class="ctx-title">{nodeContextMenu.nodeName}</div>
|
||||
<button onclick={() => {
|
||||
fetch('/api/destroy-node', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ node_id: nodeContextMenu!.nodeId }),
|
||||
}).catch(() => {});
|
||||
nodeContextMenu = null;
|
||||
}}>Destroy Node</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Hide Nodes Dialog -->
|
||||
{#if showHideDialog}
|
||||
<div class="dialog">
|
||||
@@ -681,7 +783,7 @@
|
||||
|
||||
<style>
|
||||
.wrap { width: 100%; height: 100vh; background: #14141e; position: relative; overflow: hidden; }
|
||||
.canvas { width: 100%; height: 100%; display: block; cursor: default; }
|
||||
.canvas { width: 100%; height: 100%; display: block; cursor: default; position: absolute; top: 0; left: 0; z-index: 1; }
|
||||
|
||||
.toolbar {
|
||||
position: absolute; top: 0; left: 0; right: 0; z-index: 10;
|
||||
@@ -732,6 +834,11 @@
|
||||
font-size: 12px; cursor: pointer; text-align: left; font-family: monospace;
|
||||
}
|
||||
.ctx button:hover { background: #444; }
|
||||
.ctx-title {
|
||||
padding: 4px 16px; font-size: 9px; color: #666;
|
||||
border-bottom: 1px solid #444; font-family: monospace;
|
||||
max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Dialogs */
|
||||
.dialog {
|
||||
@@ -803,4 +910,25 @@
|
||||
.vol-hitarea { cursor: pointer; }
|
||||
.vol-handle { cursor: ew-resize; pointer-events: none; }
|
||||
.vol-handle:hover { filter: brightness(1.3); }
|
||||
.add-btn { background: #1a3a2a !important; border-color: #4a9 !important; color: #6c9 !important; }
|
||||
|
||||
.virt-menu {
|
||||
position: absolute; top: 30px; z-index: 30;
|
||||
right: 80px;
|
||||
background: #2a2a3e; border: 1px solid #555;
|
||||
border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.6);
|
||||
}
|
||||
.virt-header {
|
||||
padding: 4px 16px; font-size: 9px; color: #666; text-transform: uppercase;
|
||||
letter-spacing: 1px; border-bottom: 1px solid #444;
|
||||
}
|
||||
.virt-sep { height: 1px; background: #444; margin: 2px 0; }
|
||||
.virt-menu button {
|
||||
display: block; width: 100%; padding: 6px 16px;
|
||||
background: none; border: none; color: #ccc;
|
||||
font-size: 11px; cursor: pointer; text-align: left; font-family: monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.virt-menu button:hover { background: #444; }
|
||||
|
||||
</style>
|
||||
|
||||
@@ -383,4 +383,62 @@ export async function setNodeMute(nodeId: number, mute: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
// Virtual devices
|
||||
export async function createNullSink(name: string): Promise<number | null> {
|
||||
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<number | null> {
|
||||
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<number | null> {
|
||||
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 };
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#include "graph_engine.h"
|
||||
|
||||
#include <pipewire/pipewire.h>
|
||||
#include <pipewire/impl-module.h>
|
||||
#include <pipewire/global.h>
|
||||
#include <spa/utils/result.h>
|
||||
#include <spa/utils/list.h>
|
||||
#include <spa/param/props.h>
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user